dacli-tui 0.4.0__tar.gz
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.
- dacli_tui-0.4.0/PKG-INFO +20 -0
- dacli_tui-0.4.0/README.md +5 -0
- dacli_tui-0.4.0/pyproject.toml +33 -0
- dacli_tui-0.4.0/setup.cfg +4 -0
- dacli_tui-0.4.0/src/dacli/tui/__init__.py +23 -0
- dacli_tui-0.4.0/src/dacli/tui/chat_session.py +357 -0
- dacli_tui-0.4.0/src/dacli/tui/design.py +168 -0
- dacli_tui-0.4.0/src/dacli/tui/panels.py +1003 -0
- dacli_tui-0.4.0/src/dacli/tui/render_util.py +231 -0
- dacli_tui-0.4.0/src/dacli/tui/reports.py +287 -0
- dacli_tui-0.4.0/src/dacli/tui/slash.py +644 -0
- dacli_tui-0.4.0/src/dacli/tui/stream.py +278 -0
- dacli_tui-0.4.0/src/dacli/tui/tables.py +148 -0
- dacli_tui-0.4.0/src/dacli/tui/theme.py +329 -0
- dacli_tui-0.4.0/src/dacli/tui/transcript.py +360 -0
- dacli_tui-0.4.0/src/dacli/tui/transcript_app.py +85 -0
- dacli_tui-0.4.0/src/dacli/tui/ui.py +461 -0
- dacli_tui-0.4.0/src/dacli_tui.egg-info/PKG-INFO +20 -0
- dacli_tui-0.4.0/src/dacli_tui.egg-info/SOURCES.txt +20 -0
- dacli_tui-0.4.0/src/dacli_tui.egg-info/dependency_links.txt +1 -0
- dacli_tui-0.4.0/src/dacli_tui.egg-info/requires.txt +6 -0
- dacli_tui-0.4.0/src/dacli_tui.egg-info/top_level.txt +1 -0
dacli_tui-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dacli-tui
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Rich + prompt-toolkit REPL, themes, and widgets for dacli
|
|
5
|
+
Author-email: Mouad Jaouhari <github@mj-dev.net>
|
|
6
|
+
Project-URL: Homepage, https://github.com/mouadja02/dacli
|
|
7
|
+
Keywords: tui,repl,rich,prompt-toolkit
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: dacli-core==0.4.0
|
|
11
|
+
Requires-Dist: rich<16,>=13
|
|
12
|
+
Requires-Dist: prompt-toolkit<4,>=3
|
|
13
|
+
Provides-Extra: textual
|
|
14
|
+
Requires-Dist: textual<2,>=0.50; extra == "textual"
|
|
15
|
+
|
|
16
|
+
# dacli-tui
|
|
17
|
+
|
|
18
|
+
The Rich + prompt-toolkit REPL for [dacli](https://github.com/mouadja02/dacli):
|
|
19
|
+
theme engine, widgets, transcript viewer — editable by prompt. Depends on
|
|
20
|
+
`dacli-core`.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dacli-tui"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Rich + prompt-toolkit REPL, themes, and widgets for dacli"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "Mouad Jaouhari", email = "github@mj-dev.net" }]
|
|
12
|
+
keywords = ["tui", "repl", "rich", "prompt-toolkit"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"dacli-core==0.4.0",
|
|
15
|
+
"rich>=13,<16",
|
|
16
|
+
"prompt-toolkit>=3,<4",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
# Full-screen transcript viewer (tui/transcript_app.py). Rich is the default UI;
|
|
21
|
+
# textual is lazy-imported, so a default install never loads it.
|
|
22
|
+
textual = ["textual>=0.50,<2"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/mouadja02/dacli"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.dynamic]
|
|
28
|
+
version = { attr = "dacli.tui.__version__" }
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
include = ["dacli*"]
|
|
33
|
+
namespaces = true
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""DACLI terminal UI package (Rich-based)."""
|
|
2
|
+
|
|
3
|
+
from .design import ASCII, SPACING, TIER_STYLE, UNICODE, Glyphs, gauge, resolve_glyphs
|
|
4
|
+
from .ui import DacliUI, StreamView
|
|
5
|
+
from .theme import THEMES, DEFAULT_THEME, get_theme, ThemeSpec
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ASCII",
|
|
9
|
+
"DEFAULT_THEME",
|
|
10
|
+
"SPACING",
|
|
11
|
+
"THEMES",
|
|
12
|
+
"TIER_STYLE",
|
|
13
|
+
"UNICODE",
|
|
14
|
+
"DacliUI",
|
|
15
|
+
"Glyphs",
|
|
16
|
+
"StreamView",
|
|
17
|
+
"ThemeSpec",
|
|
18
|
+
"gauge",
|
|
19
|
+
"get_theme",
|
|
20
|
+
"resolve_glyphs",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Interactive chat REPL.
|
|
2
|
+
|
|
3
|
+
Builds the agent/memory/UI, runs first-run bootstrap, then loops on input —
|
|
4
|
+
delegating ``/commands`` to :func:`dacli.tui.slash.dispatch` and everything else
|
|
5
|
+
to the agent. Extracted from ``scripts/cli.py`` so the Click surface stays thin.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from prompt_toolkit import PromptSession
|
|
14
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
15
|
+
from prompt_toolkit.history import FileHistory
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.prompt import Confirm, Prompt
|
|
18
|
+
|
|
19
|
+
from dacli.config import CLI_COMMANDS
|
|
20
|
+
from dacli.config.settings import (
|
|
21
|
+
invalidate_config_cache,
|
|
22
|
+
is_llm_configured,
|
|
23
|
+
load_config,
|
|
24
|
+
)
|
|
25
|
+
from dacli.connectors.registry import CONNECTORS_CONFIG_PATH
|
|
26
|
+
from dacli.core import __author__, __version__, paths
|
|
27
|
+
from dacli.core.host import DacliHost
|
|
28
|
+
from dacli.core.logging_setup import get_logger
|
|
29
|
+
from dacli.core.memory import AgentMemory
|
|
30
|
+
from dacli.core.onboarding import collect_llm_credentials, run_first_connection
|
|
31
|
+
from dacli.core.store import DacliStore
|
|
32
|
+
from dacli.governance.permissions import EscalationChoice, EscalationRequest
|
|
33
|
+
import dacli.tui.reports as reports
|
|
34
|
+
import dacli.tui.slash as slash
|
|
35
|
+
from dacli.tui import DacliUI
|
|
36
|
+
|
|
37
|
+
log = get_logger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _enabled_connector_names(registry, ext_registry=None) -> list:
|
|
41
|
+
# Short connector names for the welcome card / status bar.
|
|
42
|
+
# Old-path connectors (system/sandbox — internal, skip them in display).
|
|
43
|
+
catalog = registry.get_catalog()
|
|
44
|
+
names = [
|
|
45
|
+
cid for cid in catalog
|
|
46
|
+
if registry.is_connector_enabled(cid) and cid not in ("system", "sandbox")
|
|
47
|
+
]
|
|
48
|
+
# New-path extensions (seeds + user-generated).
|
|
49
|
+
if ext_registry is not None:
|
|
50
|
+
names.extend(ext_registry.extension_ids())
|
|
51
|
+
return names
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ctx_pct(memory, agent=None) -> int:
|
|
55
|
+
# Context fill for the toolbar (0-100). Prefer the assembler's real budget
|
|
56
|
+
# snapshot (cached once per turn by the context pipeline) so the number
|
|
57
|
+
# reflects true token pressure; before the first turn assembles anything,
|
|
58
|
+
# fall back to the rolling message-window proxy.
|
|
59
|
+
try:
|
|
60
|
+
last = agent._context["last_context"]() if agent is not None else None
|
|
61
|
+
budget = getattr(last, "budget", None)
|
|
62
|
+
if budget:
|
|
63
|
+
used = sum(v.get("used", 0) for v in budget.values())
|
|
64
|
+
cap = sum(v.get("cap", 0) for v in budget.values())
|
|
65
|
+
if cap > 0:
|
|
66
|
+
return min(100, max(0, round(used / cap * 100)))
|
|
67
|
+
except Exception:
|
|
68
|
+
# a toolbar glitch must never break the input loop
|
|
69
|
+
log.debug(
|
|
70
|
+
"context-percent toolbar calc failed; falling back to window", exc_info=True
|
|
71
|
+
)
|
|
72
|
+
window = max(getattr(memory, "memory_window", 0) or 1, 1)
|
|
73
|
+
used = min(len(memory.get_full_history()), window)
|
|
74
|
+
return round(used / window * 100)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def run_chat(
|
|
78
|
+
config_path: str | None, session_id: str | None, force_setup: bool = False
|
|
79
|
+
):
|
|
80
|
+
# Run the interactive chat session.
|
|
81
|
+
# Load configuration first so the UI is themed from the user's settings.
|
|
82
|
+
settings = load_config(config_path)
|
|
83
|
+
chat_ui = DacliUI(settings=settings, version=__version__, author=__author__)
|
|
84
|
+
con = chat_ui.console
|
|
85
|
+
|
|
86
|
+
chat_ui.banner()
|
|
87
|
+
chat_ui.status("Loading configuration…")
|
|
88
|
+
|
|
89
|
+
# First-run LLM bootstrap: with no usable provider/model/key, collect them
|
|
90
|
+
# interactively (key -> encrypted store, rest -> config.yaml) before any
|
|
91
|
+
# connector setup. Must run before the agent is built.
|
|
92
|
+
if not is_llm_configured(settings):
|
|
93
|
+
settings = collect_llm_credentials(
|
|
94
|
+
con, settings, store_base_dir=str(Path(settings.agent.state_path).parent)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Persistent project store (.dacli/dacli.json): startups, config snapshot, usage/cost
|
|
98
|
+
store = DacliStore(base_dir=str(Path(settings.agent.state_path).parent))
|
|
99
|
+
# Onboarding is conversational now (M12): no connector wizard. Offer a first
|
|
100
|
+
# connection once, after the host is built — it needs the live extension
|
|
101
|
+
# registry and secret store. ``--setup`` forces the offer on any run.
|
|
102
|
+
want_onboarding = force_setup or store.is_first_run()
|
|
103
|
+
|
|
104
|
+
# Initialize memory
|
|
105
|
+
memory = AgentMemory(
|
|
106
|
+
state_path=settings.agent.state_path,
|
|
107
|
+
history_path=settings.agent.history_path,
|
|
108
|
+
memory_window=settings.agent.memory_window,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Load session if specified
|
|
112
|
+
if session_id:
|
|
113
|
+
if memory.load_session(session_id):
|
|
114
|
+
chat_ui.notice(f"Loaded session: {session_id}", style="success")
|
|
115
|
+
else:
|
|
116
|
+
chat_ui.error(f"Session not found: {session_id}")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
def on_user_input_needed(question: str) -> str:
|
|
120
|
+
# Asked mid-loop (system connector); the stream is already torn down.
|
|
121
|
+
con.print(
|
|
122
|
+
Panel(
|
|
123
|
+
question,
|
|
124
|
+
title="[warning]input needed[/warning]",
|
|
125
|
+
border_style="warning",
|
|
126
|
+
padding=(1, 2),
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
return Prompt.ask("[prompt]your response[/prompt]", console=con)
|
|
130
|
+
|
|
131
|
+
def on_approval(request) -> bool:
|
|
132
|
+
# Governance: a risky/irreversible action wants sign-off. Show
|
|
133
|
+
# the blast radius, the classifier's reasoning, the rollback plan and any
|
|
134
|
+
# dry-run / shadow diff, then ask. Default is NO (fail-safe).
|
|
135
|
+
chat_ui.approval_panel(request)
|
|
136
|
+
return Confirm.ask(
|
|
137
|
+
"[prompt]Proceed with this action?[/prompt]", console=con, default=False
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def on_escalation(request: EscalationRequest) -> EscalationChoice:
|
|
141
|
+
# Scope escalation: the action exceeds the connector's granted scope.
|
|
142
|
+
# Show a panel with current/needed scope and 3 choices.
|
|
143
|
+
chat_ui.escalation_panel(request)
|
|
144
|
+
answer = Prompt.ask(
|
|
145
|
+
"[prompt]Choice[/prompt]",
|
|
146
|
+
choices=["1", "2", "3"],
|
|
147
|
+
default="3",
|
|
148
|
+
console=con,
|
|
149
|
+
)
|
|
150
|
+
return {
|
|
151
|
+
"1": EscalationChoice.ALLOW_ONCE,
|
|
152
|
+
"2": EscalationChoice.ALLOW_PERMANENTLY,
|
|
153
|
+
"3": EscalationChoice.DECLINE,
|
|
154
|
+
}.get(answer, EscalationChoice.DECLINE)
|
|
155
|
+
|
|
156
|
+
# Initialize the host (M09) — UI methods wired directly as kernel callbacks.
|
|
157
|
+
# Wrapped in a factory so a `/workspace` switch can rebuild the host for the
|
|
158
|
+
# newly active workspace without restarting the process (M15). Passing
|
|
159
|
+
# memory/store=None lets the host derive workspace-scoped ones.
|
|
160
|
+
def make_host(settings, memory=None, store=None):
|
|
161
|
+
return DacliHost(
|
|
162
|
+
settings=settings,
|
|
163
|
+
memory=memory,
|
|
164
|
+
on_status_update=chat_ui.status,
|
|
165
|
+
on_tool_start=chat_ui.tool_start,
|
|
166
|
+
on_tool_end=chat_ui.tool_end,
|
|
167
|
+
on_tool_progress=chat_ui.tool_progress,
|
|
168
|
+
on_user_input_needed=on_user_input_needed,
|
|
169
|
+
on_approval=on_approval,
|
|
170
|
+
on_escalation=on_escalation,
|
|
171
|
+
on_stream_start=chat_ui.on_stream_start,
|
|
172
|
+
on_text=chat_ui.on_text,
|
|
173
|
+
on_stream_end=chat_ui.on_stream_end,
|
|
174
|
+
connectors_config_path=CONNECTORS_CONFIG_PATH,
|
|
175
|
+
store=store,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
agent = make_host(settings, memory, store)
|
|
179
|
+
|
|
180
|
+
# Initialize connections (the agent emits its own progress via on_status).
|
|
181
|
+
con.print()
|
|
182
|
+
try:
|
|
183
|
+
if not await agent.initialize():
|
|
184
|
+
chat_ui.error("Failed to initialize agent. Check your configuration.")
|
|
185
|
+
return
|
|
186
|
+
except Exception as e:
|
|
187
|
+
chat_ui.error(f"Initialization error: {e}")
|
|
188
|
+
chat_ui.notice(
|
|
189
|
+
"Check your config.yaml and ensure credentials are set.", style="muted"
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
# Point the transcript log at the session spill store so /expand can fetch
|
|
194
|
+
# an elided result back off-context instead of re-running the tool (P11).
|
|
195
|
+
spill_store = getattr(agent, "_context", {}).get("store")
|
|
196
|
+
if spill_store is not None:
|
|
197
|
+
chat_ui.bind_result_store(spill_store)
|
|
198
|
+
|
|
199
|
+
# Persist startup + a secret-redacted snapshot of the effective config.
|
|
200
|
+
store.record_startup()
|
|
201
|
+
store.snapshot_config(settings)
|
|
202
|
+
store.save()
|
|
203
|
+
|
|
204
|
+
# Conversational first-connection onboarding (M12). Skippable; declining
|
|
205
|
+
# leaves an empty ~/.dacli untouched.
|
|
206
|
+
if want_onboarding:
|
|
207
|
+
con.print()
|
|
208
|
+
run_first_connection(chat_ui, con, agent._ext_registry, agent.secrets)
|
|
209
|
+
invalidate_config_cache()
|
|
210
|
+
settings = load_config(config_path)
|
|
211
|
+
|
|
212
|
+
con.print()
|
|
213
|
+
resolved_config = paths.resolve_config_path(config_path)
|
|
214
|
+
chat_ui.welcome(
|
|
215
|
+
model=settings.llm.model,
|
|
216
|
+
provider=settings.llm.provider,
|
|
217
|
+
connectors=_enabled_connector_names(agent.registry, agent._ext_registry),
|
|
218
|
+
cwd=str(Path.cwd()),
|
|
219
|
+
config=str(resolved_config) if resolved_config else None,
|
|
220
|
+
state=str(paths.state_dir().resolve()),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Surface any connectors the registry had to skip (bad manifest / import
|
|
224
|
+
# error / failing operations()) so a broken — often freshly generated —
|
|
225
|
+
# connector is visible instead of silently missing.
|
|
226
|
+
failed = agent.registry.failed_connectors()
|
|
227
|
+
if failed:
|
|
228
|
+
for cid, reason in failed.items():
|
|
229
|
+
chat_ui.notice(f"Connector '{cid}' was skipped: {reason}", style="warning")
|
|
230
|
+
chat_ui.notice(
|
|
231
|
+
"Fix it with /debug-connector, then /import-connector to re-enable.",
|
|
232
|
+
style="muted",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Mutable session state the slash handlers read and (for reloads) write.
|
|
236
|
+
ctx = slash.ChatContext(
|
|
237
|
+
ui=chat_ui,
|
|
238
|
+
console=con,
|
|
239
|
+
memory=memory,
|
|
240
|
+
agent=agent,
|
|
241
|
+
store=store,
|
|
242
|
+
settings=settings,
|
|
243
|
+
config_path=config_path,
|
|
244
|
+
make_host=make_host,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Set up prompt toolkit for better input.
|
|
248
|
+
# Resolve to absolute so prompt_toolkit can always find the file on exit,
|
|
249
|
+
# even if something shifts cwd mid-session.
|
|
250
|
+
history_file = (Path(settings.agent.history_path) / "input_history.txt").resolve()
|
|
251
|
+
history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
|
|
253
|
+
# The toolbar reads ctx.* (not the construction-time locals) so a `/workspace`
|
|
254
|
+
# switch, which swaps ctx.agent/memory/store, is reflected without a restart.
|
|
255
|
+
def _session_cost() -> str:
|
|
256
|
+
# Live per-session $cost for the bottom bar; blank on any hiccup.
|
|
257
|
+
# O(1) lookup — the toolbar recomputes this on every keystroke.
|
|
258
|
+
try:
|
|
259
|
+
return reports.fmt_cost(ctx.store.session_cost_usd(ctx.memory.session_id))
|
|
260
|
+
except Exception:
|
|
261
|
+
return ""
|
|
262
|
+
|
|
263
|
+
def _warehouse_cost() -> str:
|
|
264
|
+
# Live per-session warehouse $spend (P14), shown next to the LLM cost.
|
|
265
|
+
# Blank until a governed warehouse action records an estimate.
|
|
266
|
+
try:
|
|
267
|
+
usd = ctx.store.session_warehouse_usd(ctx.memory.session_id)
|
|
268
|
+
return reports.fmt_cost(usd) if usd else ""
|
|
269
|
+
except Exception:
|
|
270
|
+
return ""
|
|
271
|
+
|
|
272
|
+
def bottom_toolbar():
|
|
273
|
+
from dacli.core.test_mode import test_mode as _tm
|
|
274
|
+
|
|
275
|
+
return chat_ui.bottom_toolbar(
|
|
276
|
+
provider=ctx.settings.llm.provider,
|
|
277
|
+
model=ctx.settings.llm.model,
|
|
278
|
+
connectors=_enabled_connector_names(
|
|
279
|
+
ctx.agent.registry, ctx.agent._ext_registry
|
|
280
|
+
),
|
|
281
|
+
ctx_pct=_ctx_pct(ctx.memory, ctx.agent),
|
|
282
|
+
session=ctx.memory.session_id,
|
|
283
|
+
test_mode=_tm.toolbar_text(),
|
|
284
|
+
cost=_session_cost(),
|
|
285
|
+
wh_cost=_warehouse_cost(),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
pt_session = PromptSession(
|
|
289
|
+
history=FileHistory(str(history_file)),
|
|
290
|
+
auto_suggest=AutoSuggestFromHistory(),
|
|
291
|
+
completer=slash.SlashCommandCompleter(CLI_COMMANDS),
|
|
292
|
+
complete_while_typing=True,
|
|
293
|
+
key_bindings=slash.build_completion_keybindings(),
|
|
294
|
+
bottom_toolbar=bottom_toolbar,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
while True:
|
|
299
|
+
try:
|
|
300
|
+
user_input = await asyncio.to_thread(
|
|
301
|
+
pt_session.prompt,
|
|
302
|
+
chat_ui.prompt_html(),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if not user_input.strip():
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
# Slash commands run through the registry; everything else is a
|
|
309
|
+
# message for the agent.
|
|
310
|
+
if user_input.startswith("/"):
|
|
311
|
+
await slash.dispatch(ctx, user_input)
|
|
312
|
+
if ctx.should_exit:
|
|
313
|
+
break
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
# Process the message with the agent. The kernel streams text +
|
|
317
|
+
# tool calls to the UI as it runs, so there is nothing to print
|
|
318
|
+
# here on success — only errors / hand-offs need a notice.
|
|
319
|
+
# (prompt_toolkit already leaves the typed "❯ …" line in the
|
|
320
|
+
# scrollback, so we don't re-echo it.)
|
|
321
|
+
con.print()
|
|
322
|
+
if getattr(ctx.settings.ui, "show_header", False):
|
|
323
|
+
chat_ui.turn_header(
|
|
324
|
+
model=ctx.settings.llm.model, session=ctx.memory.session_id
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
response = await ctx.agent.process_message(user_input)
|
|
329
|
+
except KeyboardInterrupt:
|
|
330
|
+
chat_ui.stream.abort()
|
|
331
|
+
chat_ui.notice("Interrupted.", style="warning")
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
if response.error:
|
|
335
|
+
chat_ui.error(f"Error: {response.error}")
|
|
336
|
+
|
|
337
|
+
if response.needs_user_input:
|
|
338
|
+
chat_ui.notice(
|
|
339
|
+
"⏳ Agent is waiting for your input to continue.",
|
|
340
|
+
style="warning",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
con.print()
|
|
344
|
+
|
|
345
|
+
except KeyboardInterrupt:
|
|
346
|
+
chat_ui.stream.abort()
|
|
347
|
+
con.print("\n[muted]Use /exit to quit[/muted]")
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
except EOFError:
|
|
351
|
+
break
|
|
352
|
+
|
|
353
|
+
finally:
|
|
354
|
+
chat_ui.stream.abort()
|
|
355
|
+
con.print("\n[muted]Cleaning up…[/muted]")
|
|
356
|
+
await ctx.agent.shutdown()
|
|
357
|
+
chat_ui.notice("Goodbye 👋", style="success")
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Design tokens for the DACLI terminal UI.
|
|
2
|
+
|
|
3
|
+
Single source of truth for **spacing, glyphs, box styles and semantic color
|
|
4
|
+
maps** so every renderer in ``tui/ui.py`` makes the same choices. Two glyph
|
|
5
|
+
sets exist: ``UNICODE`` for capable terminals and ``ASCII`` for everything
|
|
6
|
+
else (non-UTF-8 encodings, ``TERM=dumb``, ``NO_COLOR`` environments, or an
|
|
7
|
+
explicit ``ui.glyphs: ascii`` setting). :func:`resolve_glyphs` picks one.
|
|
8
|
+
|
|
9
|
+
Reliability note: this module is presentation-only and import-safe — it never
|
|
10
|
+
touches config, credentials or the control loop. Capability detection is
|
|
11
|
+
best-effort and degrades to ASCII on any doubt.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from rich import box
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Glyphs:
|
|
26
|
+
"""One coherent glyph set; every UI marker comes from here."""
|
|
27
|
+
|
|
28
|
+
# Transcript gutter markers.
|
|
29
|
+
agent: str
|
|
30
|
+
tool: str
|
|
31
|
+
result: str
|
|
32
|
+
user_caret: str
|
|
33
|
+
# Status icons (always paired with color, never color-only).
|
|
34
|
+
ok: str
|
|
35
|
+
warn: str
|
|
36
|
+
err: str
|
|
37
|
+
info: str
|
|
38
|
+
pending: str
|
|
39
|
+
running: str
|
|
40
|
+
paused: str
|
|
41
|
+
# Connector enablement dots.
|
|
42
|
+
enabled: str
|
|
43
|
+
disabled: str
|
|
44
|
+
# Misc affordances.
|
|
45
|
+
caret: str # streaming tail caret
|
|
46
|
+
hint: str # remediation-hint arrow
|
|
47
|
+
gauge_on: str # filled cell of the context gauge
|
|
48
|
+
gauge_off: str # empty cell of the context gauge
|
|
49
|
+
ellipsis: str
|
|
50
|
+
dot: str # separator dot in summaries ("· 340ms")
|
|
51
|
+
dash: str # placeholder dash in empty table cells
|
|
52
|
+
arrows: str # history-keys hint in the welcome tips
|
|
53
|
+
delta: str # change marker in diff/plan tables ("Δ rows")
|
|
54
|
+
mult: str # breadth-first multiplier ("×3")
|
|
55
|
+
# Bottom-bar segment icons (empty in ASCII mode; labels carry meaning).
|
|
56
|
+
bar_conn: str
|
|
57
|
+
bar_ctx: str
|
|
58
|
+
bar_session: str
|
|
59
|
+
bar_sep: str
|
|
60
|
+
# Spinner animation frames (one frame per char/element).
|
|
61
|
+
spinner_frames: str
|
|
62
|
+
# Box style for panels/tables.
|
|
63
|
+
box: box.Box
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
UNICODE = Glyphs(
|
|
67
|
+
agent="⏺", tool="⏺", result="⎿", user_caret="❯",
|
|
68
|
+
ok="✓", warn="⚠", err="✗", info="ℹ", pending="○", running="◐", paused="⏸",
|
|
69
|
+
enabled="●", disabled="○",
|
|
70
|
+
caret="▌", hint="↳", gauge_on="▰", gauge_off="▱", ellipsis="…",
|
|
71
|
+
dot="·", dash="—", arrows="↑↓", delta="Δ", mult="×",
|
|
72
|
+
bar_conn="⛁ ", bar_ctx="◴ ", bar_session="⎇ ", bar_sep="│",
|
|
73
|
+
spinner_frames="⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏",
|
|
74
|
+
box=box.ROUNDED,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
ASCII = Glyphs(
|
|
78
|
+
agent="*", tool="*", result=">", user_caret=">",
|
|
79
|
+
ok="+", warn="!", err="x", info="i", pending="o", running="~", paused="=",
|
|
80
|
+
enabled="*", disabled="o",
|
|
81
|
+
caret="|", hint="->", gauge_on="#", gauge_off="-", ellipsis="...",
|
|
82
|
+
dot=".", dash="-", arrows="Up/Down", delta="d", mult="x",
|
|
83
|
+
bar_conn="", bar_ctx="", bar_session="", bar_sep="|",
|
|
84
|
+
spinner_frames="|/-\\",
|
|
85
|
+
box=box.ASCII,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# One spacing system: gutter width, body indent, panel padding, section gap.
|
|
89
|
+
SPACING: dict[str, Any] = {
|
|
90
|
+
"gutter_w": 1,
|
|
91
|
+
"indent": 2,
|
|
92
|
+
"panel_pad": (1, 2),
|
|
93
|
+
"section_gap": 1,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Blast-radius tier → semantic style. Shared by the audit view, the plan
|
|
97
|
+
# preview and the approval panel so "risky" reads the same color everywhere.
|
|
98
|
+
TIER_STYLE = {
|
|
99
|
+
"safe": "success",
|
|
100
|
+
"write": "info",
|
|
101
|
+
"risky": "warning",
|
|
102
|
+
"irreversible": "error",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def tier_legend(dot: str = "·") -> Text:
|
|
107
|
+
"""A one-line ``safe · write · risky · irreversible`` key in tier colors.
|
|
108
|
+
|
|
109
|
+
Shared under the dense governance panels (plan, audit, approval) so the
|
|
110
|
+
blast-radius colors are decodable without memorizing them.
|
|
111
|
+
"""
|
|
112
|
+
legend = Text()
|
|
113
|
+
for i, (name, style) in enumerate(TIER_STYLE.items()):
|
|
114
|
+
if i:
|
|
115
|
+
legend.append(f" {dot} ", style="muted")
|
|
116
|
+
legend.append(name, style=style)
|
|
117
|
+
return legend
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def gauge(pct: Any, glyphs: Glyphs, cells: int = 5) -> str:
|
|
121
|
+
"""Render a percentage as a tiny bar gauge, e.g. ``▰▰▰▱▱ 58%``.
|
|
122
|
+
|
|
123
|
+
Defensive: a non-numeric ``pct`` renders as an empty gauge rather than
|
|
124
|
+
raising (the status bar must never crash the input loop).
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
clamped = min(100, max(0, int(pct)))
|
|
128
|
+
except Exception:
|
|
129
|
+
clamped = 0
|
|
130
|
+
filled = round(cells * clamped / 100)
|
|
131
|
+
return glyphs.gauge_on * filled + glyphs.gauge_off * (cells - filled) + f" {clamped}%"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _console_can_encode(console: Any, probe: str = "⏺⎿✓▰") -> bool:
|
|
135
|
+
"""Best-effort: can this console's encoding represent our glyphs?"""
|
|
136
|
+
try:
|
|
137
|
+
encoding = getattr(console.options, "encoding", "") or "ascii"
|
|
138
|
+
probe.encode(encoding)
|
|
139
|
+
except Exception:
|
|
140
|
+
return False
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def resolve_glyphs(console: Any, settings: Any = None) -> Glyphs:
|
|
145
|
+
"""Pick the glyph set for this console + settings.
|
|
146
|
+
|
|
147
|
+
ASCII when: ``ui.glyphs == "ascii"``, the console can't encode Unicode,
|
|
148
|
+
``NO_COLOR`` is set, or the terminal is dumb. Unicode otherwise. An
|
|
149
|
+
explicit ``ui.glyphs == "unicode"`` wins over the heuristics.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
preference = str(
|
|
153
|
+
getattr(getattr(settings, "ui", None), "glyphs", "auto") or "auto"
|
|
154
|
+
).strip().lower()
|
|
155
|
+
except Exception:
|
|
156
|
+
preference = "auto"
|
|
157
|
+
if preference == "ascii":
|
|
158
|
+
return ASCII
|
|
159
|
+
if preference == "unicode":
|
|
160
|
+
return UNICODE
|
|
161
|
+
# auto: degrade on any capability doubt.
|
|
162
|
+
if os.environ.get("NO_COLOR"):
|
|
163
|
+
return ASCII
|
|
164
|
+
if os.environ.get("TERM", "").lower() == "dumb":
|
|
165
|
+
return ASCII
|
|
166
|
+
if not _console_can_encode(console):
|
|
167
|
+
return ASCII
|
|
168
|
+
return UNICODE
|