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.
@@ -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,5 @@
1
+ # dacli-tui
2
+
3
+ The Rich + prompt-toolkit REPL for [dacli](https://github.com/mouadja02/dacli):
4
+ theme engine, widgets, transcript viewer — editable by prompt. Depends on
5
+ `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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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