delos-cli 1.0.2__tar.gz → 1.0.3__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.
Files changed (77) hide show
  1. delos_cli-1.0.3/.claude/settings.local.json +8 -0
  2. {delos_cli-1.0.2 → delos_cli-1.0.3}/PKG-INFO +1 -1
  3. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/chat/app.py +9 -0
  4. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/chat/commands.py +34 -1
  5. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/chat/render.py +14 -0
  6. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/replay.py +3 -1
  7. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/commands/prompts.py +6 -0
  8. delos_cli-1.0.3/delos_cli/context_report.py +176 -0
  9. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/serve/server.py +48 -0
  10. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/__init__.py +10 -0
  11. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/edit_content.py +18 -4
  12. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/explore.py +7 -2
  13. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/run_shell.py +33 -5
  14. delos_cli-1.0.3/delos_cli/tools/verify.py +347 -0
  15. delos_cli-1.0.3/delos_cli/ui/banner.py +102 -0
  16. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/chat_picker.py +50 -22
  17. delos_cli-1.0.3/delos_cli/ui/completer.py +135 -0
  18. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/lexer.py +14 -1
  19. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/output.py +4 -1
  20. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/repl.py +65 -6
  21. delos_cli-1.0.3/delos_cli/ui/style.py +51 -0
  22. {delos_cli-1.0.2 → delos_cli-1.0.3}/pyproject.toml +1 -1
  23. delos_cli-1.0.2/delos_cli/apps/chat/commands.py.bak +0 -17
  24. delos_cli-1.0.2/delos_cli/ui/banner.py +0 -51
  25. delos_cli-1.0.2/delos_cli/ui/completer.py +0 -68
  26. delos_cli-1.0.2/delos_cli/ui/style.py +0 -24
  27. {delos_cli-1.0.2 → delos_cli-1.0.3}/.gitignore +0 -0
  28. {delos_cli-1.0.2 → delos_cli-1.0.3}/DELOS.md +0 -0
  29. {delos_cli-1.0.2 → delos_cli-1.0.3}/README.md +0 -0
  30. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/__init__.py +0 -0
  31. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/agent/__init__.py +0 -0
  32. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/agent/session.py +0 -0
  33. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/agent/tools.py +0 -0
  34. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/agent/transport.py +0 -0
  35. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/agent/turn.py +0 -0
  36. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/__init__.py +0 -0
  37. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/base.py +0 -0
  38. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/chat/__init__.py +0 -0
  39. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/scribe/__init__.py +0 -0
  40. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/scribe/app.py +0 -0
  41. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/scribe/commands.py +0 -0
  42. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/scribe/tools.py +0 -0
  43. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/auth/__init__.py +0 -0
  44. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/auth/config.py +0 -0
  45. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/auth/mfa.py +0 -0
  46. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/auth/oauth.py +0 -0
  47. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/auth/token_manager.py +0 -0
  48. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/commands/__init__.py +0 -0
  49. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/commands/base.py +0 -0
  50. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/commands/builtin.py +0 -0
  51. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ctx.py +0 -0
  52. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/git_context.py +0 -0
  53. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/loop.py +0 -0
  54. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/main.py +0 -0
  55. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/project_context.py +0 -0
  56. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/serve/__init__.py +0 -0
  57. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/serve/app.py +0 -0
  58. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/serve/confirm.py +0 -0
  59. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/serve/protocol.py +0 -0
  60. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/serve/rpc.py +0 -0
  61. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/state.py +0 -0
  62. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/glob_tool.py +0 -0
  63. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/grep.py +0 -0
  64. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/read.py +0 -0
  65. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/task.py +0 -0
  66. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/todo_write.py +0 -0
  67. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/write_content.py +0 -0
  68. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/transport/__init__.py +0 -0
  69. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/transport/chats.py +0 -0
  70. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/transport/client.py +0 -0
  71. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/transport/documents.py +0 -0
  72. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/transport/integrations.py +0 -0
  73. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/transport/models.py +0 -0
  74. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/__init__.py +0 -0
  75. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/document_picker.py +0 -0
  76. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/integrations_picker.py +0 -0
  77. {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/model_picker.py +0 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "disabledMcpjsonServers": [
3
+ "sentry",
4
+ "agent-analysis-localhost",
5
+ "agent-analysis-staging",
6
+ "agent-analysis-prod"
7
+ ]
8
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: delos-cli
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: Terminal REPL for the Delos agent — the Claude-Code-style CLI for Delos.
5
5
  Project-URL: Homepage, https://delos.so
6
6
  Project-URL: Repository, https://github.com/Delos-Intelligence/cosmos-saas
@@ -29,16 +29,20 @@ from delos_cli.tools import (
29
29
  handle_explore,
30
30
  handle_glob,
31
31
  handle_grep,
32
+ handle_lint_target,
32
33
  handle_read,
33
34
  handle_run_shell,
35
+ handle_run_tests,
34
36
  handle_todo_write,
35
37
  handle_write_content,
36
38
  render_edit_content,
37
39
  render_explore,
38
40
  render_glob,
39
41
  render_grep,
42
+ render_lint_target,
40
43
  render_read,
41
44
  render_run_shell,
45
+ render_run_tests,
42
46
  render_task,
43
47
  render_todo_write,
44
48
  render_write_content,
@@ -82,6 +86,11 @@ def default_tool_registry() -> ToolRegistry:
82
86
  # only its report — see delos_cli.tools.explore.
83
87
  reg.handlers["explore"] = handle_explore
84
88
  reg.renderers["explore"] = render_explore
89
+ # Verification tools — runner/linter auto-detection, see delos_cli.tools.verify.
90
+ reg.handlers["run_tests"] = handle_run_tests
91
+ reg.renderers["run_tests"] = render_run_tests
92
+ reg.handlers["lint_target"] = handle_lint_target
93
+ reg.renderers["lint_target"] = render_lint_target
85
94
  return reg
86
95
 
87
96
 
@@ -18,6 +18,7 @@ Two modes:
18
18
  from __future__ import annotations
19
19
 
20
20
  import contextlib
21
+ from pathlib import Path
21
22
  from typing import TYPE_CHECKING, Any
22
23
 
23
24
  from prompt_toolkit.application import get_app
@@ -25,6 +26,8 @@ from rich.text import Text
25
26
 
26
27
  from delos_cli.commands.base import CommandSpec, registry_from
27
28
  from delos_cli.commands.prompts import EXECUTE_PROMPT, INIT_PROMPT_NEW, INIT_PROMPT_UPDATE
29
+ from delos_cli.context_report import fetch_context_report, render_report
30
+ from delos_cli.git_context import collect_git_context
28
31
  from delos_cli.transport.chats import patch_chat_model
29
32
  from delos_cli.transport.client import TransportError
30
33
 
@@ -128,4 +131,34 @@ EXECUTE = CommandSpec(
128
131
  )
129
132
 
130
133
 
131
- CHAT_COMMANDS: dict[str, CommandSpec] = registry_from([MODEL, INIT, EXECUTE])
134
+ # ---------------------------------------------------------------------------
135
+ # /context — token breakdown of the next turn's LLM payload
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ async def _handle_context(ctx: Ctx, args: str) -> None:
140
+ """Fetch the backend context-report for this chat and render it."""
141
+ _ = args
142
+ sink = _sink(ctx)
143
+ git_ctx = await collect_git_context(Path.cwd())
144
+ try:
145
+ report = await fetch_context_report(
146
+ ctx.http,
147
+ ctx.state.conv_id,
148
+ custom_instructions=ctx.custom_instructions or None,
149
+ git_context=git_ctx,
150
+ )
151
+ except TransportError as e:
152
+ sink.print(Text(f"context report failed: {e}", style="red"))
153
+ return
154
+ sink.print(render_report(report))
155
+
156
+
157
+ CONTEXT = CommandSpec(
158
+ name="/context",
159
+ summary="show what fills the agent's context window (token breakdown)",
160
+ handler=_handle_context,
161
+ )
162
+
163
+
164
+ CHAT_COMMANDS: dict[str, CommandSpec] = registry_from([MODEL, INIT, EXECUTE, CONTEXT])
@@ -55,6 +55,10 @@ class V6Renderer:
55
55
  self._spinner = Spinner("dots", text=" thinking", style="dim")
56
56
  self._thinking = False
57
57
  self._anim_task: asyncio.Task[None] | None = None
58
+ # Inside a <think>…</think> fence — AgentV2 streams model reasoning
59
+ # inline between literal tag deltas. Reasoning is swallowed (the
60
+ # spinner keeps running) so it never lands in scrollback.
61
+ self._in_think = False
58
62
 
59
63
  def apply(self, event: dict[str, Any]) -> None:
60
64
  """Apply a single v6 event to the output buffer."""
@@ -76,6 +80,16 @@ class V6Renderer:
76
80
  delta = event.get("delta", "")
77
81
  if not delta:
78
82
  return
83
+ # Each <think> / </think> tag arrives as its own delta
84
+ # (agent_v2 injects them around reasoning), so plain equality
85
+ # checks are enough — no tag can be split across deltas.
86
+ if delta == "<think>":
87
+ self._in_think = True
88
+ return
89
+ if self._in_think:
90
+ if delta.startswith("</think>"):
91
+ self._in_think = False
92
+ return
79
93
  self._stop_thinking()
80
94
  self._assistant_text += delta
81
95
  self._output.update_live(Markdown(self._assistant_text))
@@ -20,6 +20,7 @@ from rich.markdown import Markdown
20
20
  from rich.text import Text
21
21
 
22
22
  from delos_cli.agent import default_renderer
23
+ from delos_cli.ui.style import user_echo
23
24
 
24
25
  if TYPE_CHECKING:
25
26
  from delos_cli.apps.base import App
@@ -49,7 +50,8 @@ def replay_messages(
49
50
  if role == "user":
50
51
  text = _extract_text(msg.get("content"))
51
52
  if text:
52
- output.print(Text(f"» {text}\n", style="bold cyan"))
53
+ output.print(user_echo(text))
54
+ output.print(Text(""))
53
55
  elif role == "assistant":
54
56
  text = _extract_text(msg.get("content"))
55
57
  if text:
@@ -40,6 +40,8 @@ as you go. If no plan exists in this conversation, say so instead of \
40
40
  improvising one."""
41
41
 
42
42
  #: Catalogue served to frontends (``list_commands`` RPC) for autocomplete.
43
+ #: ``/context`` is not a prompt expansion — serve intercepts it and answers
44
+ #: locally — but it belongs in the same autocomplete list.
43
45
  PROMPT_COMMANDS: tuple[dict[str, str], ...] = (
44
46
  {
45
47
  "name": "/init",
@@ -49,6 +51,10 @@ PROMPT_COMMANDS: tuple[dict[str, str], ...] = (
49
51
  "name": "/execute",
50
52
  "summary": "execute the plan from the previous plan-mode turn (loads it into todos)",
51
53
  },
54
+ {
55
+ "name": "/context",
56
+ "summary": "show what fills the agent's context window (token breakdown)",
57
+ },
52
58
  )
53
59
 
54
60
 
@@ -0,0 +1,176 @@
1
+ """``/context`` — fetch and render the agent's context-window breakdown.
2
+
3
+ The backend endpoint (``POST /cli/chats/{id}/context-report``) rebuilds
4
+ exactly what the next turn would send to the LLM and returns a token
5
+ breakdown. This module owns the client side: the transport call and two
6
+ renderings of the same report — Rich for the REPL, markdown for serve
7
+ mode (displayed as a synthetic assistant message in VSCode).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from rich.text import Text
15
+
16
+ if TYPE_CHECKING:
17
+ from delos_cli.transport.client import AuthedClient
18
+
19
+ _BAR_WIDTH = 30
20
+
21
+
22
+ async def fetch_context_report(
23
+ http: AuthedClient,
24
+ chat_id: str,
25
+ *,
26
+ custom_instructions: str | None,
27
+ git_context: str | None,
28
+ ) -> dict[str, Any]:
29
+ """POST ``/cli/chats/{chat_id}/context-report`` and return the breakdown."""
30
+ return await http.json_post(
31
+ f"/cli/chats/{chat_id}/context-report",
32
+ {
33
+ "org_uuid": http.cfg.org_uuid,
34
+ "custom_instructions": custom_instructions,
35
+ "git_context": git_context,
36
+ },
37
+ )
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Shared shaping
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def _fmt(n: int) -> str:
46
+ """Compact token count: 1234 → ``1.2k``."""
47
+ if n >= 1000: # noqa: PLR2004 — display threshold, not business logic
48
+ return f"{n / 1000:.1f}k"
49
+ return str(n)
50
+
51
+
52
+ def _lines(report: dict[str, Any]) -> list[tuple[str, str]]:
53
+ """(label, value) pairs shared by both renderings."""
54
+ msgs = report.get("messages") or {}
55
+ by_role = msgs.get("by_role") or {}
56
+ out = [
57
+ ("Model", str(report.get("model", "?"))),
58
+ (
59
+ "System prompt",
60
+ f"{_fmt(report.get('system_prompt_tokens', 0))} tokens",
61
+ ),
62
+ (
63
+ "Tools",
64
+ (
65
+ f"{_fmt((report.get('tools') or {}).get('tokens', 0))} tokens "
66
+ f"({(report.get('tools') or {}).get('count', 0)} tools)"
67
+ ),
68
+ ),
69
+ (
70
+ "Messages",
71
+ f"{_fmt(msgs.get('tokens', 0))} tokens ({msgs.get('count', 0)} messages)",
72
+ ),
73
+ ]
74
+ for role in ("user", "assistant", "tool"):
75
+ stats = by_role.get(role)
76
+ if stats:
77
+ out.append(
78
+ (
79
+ f" {role}",
80
+ f"{_fmt(stats.get('tokens', 0))} tokens ({stats.get('count', 0)})",
81
+ ),
82
+ )
83
+ compacted = report.get("compacted_messages", 0)
84
+ if compacted:
85
+ out.append(("Compacted away", f"{compacted} messages (summarised)"))
86
+ return out
87
+
88
+
89
+ def _usage(report: dict[str, Any]) -> tuple[int, int, int, float]:
90
+ """(total, window, threshold, ratio) with safe fallbacks."""
91
+ total = int(report.get("total_tokens", 0))
92
+ window = int(report.get("context_window", 0)) or 1
93
+ threshold = int(report.get("compact_threshold", 0))
94
+ return total, window, threshold, min(1.0, total / window)
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # REPL rendering (Rich)
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ def render_report(report: dict[str, Any]) -> Text:
103
+ """Rich rendering for the REPL output buffer."""
104
+ total, window, threshold, ratio = _usage(report)
105
+ filled = round(ratio * _BAR_WIDTH)
106
+ bar_style = "red" if total >= threshold else "yellow" if ratio > 0.5 else "green" # noqa: PLR2004
107
+
108
+ text = Text()
109
+ text.append("Context usage\n", style="bold")
110
+ text.append("█" * filled, style=bar_style)
111
+ text.append("░" * (_BAR_WIDTH - filled), style="dim")
112
+ text.append(f" {_fmt(total)} / {_fmt(window)} tokens ({ratio:.0%})\n", style="bold")
113
+ text.append(f"auto-compaction at {_fmt(threshold)}\n\n", style="dim")
114
+
115
+ for label, value in _lines(report):
116
+ text.append(f"{label:<16}", style="cyan" if not label.startswith(" ") else "dim")
117
+ text.append(f"{value}\n")
118
+
119
+ by_tool = (report.get("messages") or {}).get("by_tool") or []
120
+ if by_tool:
121
+ text.append("\nHeaviest tools\n", style="bold")
122
+ for t in by_tool:
123
+ text.append(f" {t.get('name', '?'):<24}", style="magenta")
124
+ text.append(f"{_fmt(t.get('tokens', 0)):>8} ({t.get('calls', 0)} calls)\n")
125
+
126
+ top = (report.get("messages") or {}).get("top") or []
127
+ if top:
128
+ text.append("\nLargest messages\n", style="bold")
129
+ for m in top:
130
+ who = m.get("tool") or m.get("role", "?")
131
+ text.append(f" #{m.get('index', 0):<4} {who:<20}", style="dim")
132
+ text.append(f"{_fmt(m.get('tokens', 0)):>8} ")
133
+ text.append(f"{m.get('preview', '')[:60]}\n", style="dim")
134
+ return text
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Serve rendering (markdown — shown as an assistant message in VSCode)
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ def format_report_markdown(report: dict[str, Any]) -> str:
143
+ """Markdown rendering for serve mode."""
144
+ total, window, threshold, ratio = _usage(report)
145
+ filled = round(ratio * _BAR_WIDTH)
146
+ lines = [
147
+ "**Context usage**",
148
+ "",
149
+ (
150
+ f"`{'█' * filled}{'░' * (_BAR_WIDTH - filled)}` "
151
+ f"**{_fmt(total)} / {_fmt(window)} tokens ({ratio:.0%})** "
152
+ f"— auto-compaction at {_fmt(threshold)}"
153
+ ),
154
+ "",
155
+ "| | |",
156
+ "|---|---|",
157
+ ]
158
+ lines.extend(f"| {label.strip()} | {value} |" for label, value in _lines(report))
159
+
160
+ by_tool = (report.get("messages") or {}).get("by_tool") or []
161
+ if by_tool:
162
+ lines += ["", "**Heaviest tools**", "", "| tool | tokens | calls |", "|---|---|---|"]
163
+ lines.extend(
164
+ f"| {t.get('name', '?')} | {_fmt(t.get('tokens', 0))} | {t.get('calls', 0)} |"
165
+ for t in by_tool
166
+ )
167
+
168
+ top = (report.get("messages") or {}).get("top") or []
169
+ if top:
170
+ lines += ["", "**Largest messages**", ""]
171
+ lines.extend(
172
+ f"- `#{m.get('index', 0)}` {m.get('tool') or m.get('role', '?')} — "
173
+ f"{_fmt(m.get('tokens', 0))} tokens — {m.get('preview', '')[:60]}"
174
+ for m in top
175
+ )
176
+ return "\n".join(lines)
@@ -20,6 +20,7 @@ from delos_cli.agent import AgentSession, AgentTransport
20
20
  from delos_cli.auth import config as cfg_mod
21
21
  from delos_cli.auth.oauth import OAuthError, run_login_flow
22
22
  from delos_cli.commands.prompts import PROMPT_COMMANDS, expand_slash_command
23
+ from delos_cli.context_report import fetch_context_report, format_report_markdown
23
24
  from delos_cli.ctx import ConfirmOutcome, Ctx
24
25
  from delos_cli.git_context import collect_git_context
25
26
  from delos_cli.project_context import load_project_context
@@ -174,6 +175,14 @@ class StdioServer:
174
175
  self.send({"type": "error", "message": "user_message needs chatId and content"})
175
176
  return
176
177
 
178
+ # `/context` never reaches the agent: it's a local diagnostic that
179
+ # renders the backend's context-report as a synthetic assistant
180
+ # message (start / text-delta / finish), so it works from the
181
+ # VSCode input without extension changes.
182
+ if content.strip() == "/context":
183
+ await self._run_context_report(chat_id, turn_id)
184
+ return
185
+
177
186
  # One active turn per chat — a new message supersedes the old one
178
187
  # (same semantics as the extension's previous abort-then-send).
179
188
  if (existing := self.turns.get(chat_id)) and not existing.done():
@@ -278,6 +287,45 @@ class StdioServer:
278
287
  self._log(f"turn failed for {chat_id}: {error_text}")
279
288
  self.send(done)
280
289
 
290
+ async def _run_context_report(self, chat_id: str, turn_id: str) -> None:
291
+ """Handle ``/context``: fetch the token breakdown, emit it as a turn."""
292
+ self.send({"type": "turn_started", "chatId": chat_id, "turnId": turn_id})
293
+ error_text: str | None = None
294
+ text: str | None = None
295
+ try:
296
+ http = self.require_http()
297
+ git_ctx = await collect_git_context(self.workspace)
298
+ report = await fetch_context_report(
299
+ http,
300
+ chat_id,
301
+ custom_instructions=self.custom_instructions or None,
302
+ git_context=git_ctx,
303
+ )
304
+ text = format_report_markdown(report)
305
+ except Exception as e:
306
+ error_text = f"context report failed: {type(e).__name__}: {e}"
307
+ if text is not None:
308
+ message_id = str(uuid.uuid4())
309
+ for event in (
310
+ {"type": "start", "messageId": message_id},
311
+ {"type": "text-delta", "id": message_id, "delta": text},
312
+ {"type": "finish"},
313
+ ):
314
+ self.send({
315
+ "type": "event",
316
+ "chatId": chat_id,
317
+ "turnId": turn_id,
318
+ "event": event,
319
+ })
320
+ done: dict[str, Any] = {
321
+ "type": "turn_finished",
322
+ "chatId": chat_id,
323
+ "turnId": turn_id,
324
+ }
325
+ if error_text:
326
+ done["error"] = error_text
327
+ self.send(done)
328
+
281
329
  async def _abort_turn(self, chat_id: str) -> None:
282
330
  if not chat_id:
283
331
  return
@@ -14,6 +14,12 @@ from .read import handle_read, render_read
14
14
  from .run_shell import handle_run_shell, render_run_shell
15
15
  from .task import render_task
16
16
  from .todo_write import handle_todo_write, render_todo_write
17
+ from .verify import (
18
+ handle_lint_target,
19
+ handle_run_tests,
20
+ render_lint_target,
21
+ render_run_tests,
22
+ )
17
23
  from .write_content import handle_write_content, render_write_content
18
24
 
19
25
  __all__ = [
@@ -21,16 +27,20 @@ __all__ = [
21
27
  "handle_explore",
22
28
  "handle_glob",
23
29
  "handle_grep",
30
+ "handle_lint_target",
24
31
  "handle_read",
25
32
  "handle_run_shell",
33
+ "handle_run_tests",
26
34
  "handle_todo_write",
27
35
  "handle_write_content",
28
36
  "render_edit_content",
29
37
  "render_explore",
30
38
  "render_glob",
31
39
  "render_grep",
40
+ "render_lint_target",
32
41
  "render_read",
33
42
  "render_run_shell",
43
+ "render_run_tests",
34
44
  "render_task",
35
45
  "render_todo_write",
36
46
  "render_write_content",
@@ -36,6 +36,12 @@ if TYPE_CHECKING:
36
36
 
37
37
  _DIFF_CONTEXT_LINES = 8
38
38
 
39
+ #: Full-line diff highlights (git/Claude-Code style): dark red / green
40
+ #: backgrounds with the terminal's default text color on top. 256-palette
41
+ #: colors render identically in truecolor and 256-color terminals.
42
+ _DEL_BG = "on color(52)"
43
+ _ADD_BG = "on color(22)"
44
+
39
45
  #: Sentinels for boundary inserts. When ``old_content`` matches one of
40
46
  #: these, the handler skips the literal-match step and prepends /
41
47
  #: appends ``new_content`` instead — typical use cases are adding
@@ -207,17 +213,25 @@ def _diff_panel(path: Path, file_text: str, new_text: str) -> Padding:
207
213
  )
208
214
  continue
209
215
  # Change block: pair the k-th deleted line with the k-th added one.
216
+ # ``Padding`` paints the background across the full cell width on
217
+ # every wrapped row — a styled ``Text`` would only tint the glyphs.
210
218
  dels = list(range(i1, i2))
211
219
  adds = list(range(j1, j2))
212
220
  for k in range(max(len(dels), len(adds))):
213
- old_no, old_text = ("", Text(""))
214
- new_no, new_text_cell = ("", Text(""))
221
+ old_no: str = ""
222
+ new_no: str = ""
223
+ old_text: RenderableType = Text("")
224
+ new_text_cell: RenderableType = Text("")
215
225
  if k < len(dels):
216
226
  old_no = str(dels[k] + 1)
217
- old_text = Text(old_lines[dels[k]], style="red")
227
+ old_text = Padding(
228
+ Text(old_lines[dels[k]]), 0, style=_DEL_BG, expand=True,
229
+ )
218
230
  if k < len(adds):
219
231
  new_no = str(adds[k] + 1)
220
- new_text_cell = Text(new_lines[adds[k]], style="green")
232
+ new_text_cell = Padding(
233
+ Text(new_lines[adds[k]]), 0, style=_ADD_BG, expand=True,
234
+ )
221
235
  table.add_row(old_no, old_text, new_no, new_text_cell)
222
236
 
223
237
  title = Text(f"✎ proposed edit → {path}", style="bold")
@@ -38,7 +38,10 @@ if TYPE_CHECKING:
38
38
 
39
39
  from delos_cli.ctx import Ctx
40
40
 
41
- _WALL_CLOCK_BUDGET_S = 300.0 # 5 minutes, mirrors the backend `task` tool
41
+ # Hard wall-clock cap: the parent turn (and the user) blocks on this. On
42
+ # timeout the handler still returns the last finished message as a partial
43
+ # report, so a lost explorer costs at most 2.5 minutes, not an open-ended wait.
44
+ _WALL_CLOCK_BUDGET_S = 150.0
42
45
  _MAX_REPORT_CHARS = 10_000
43
46
 
44
47
  #: Reasoning blocks some models emit inline in their text — never part of
@@ -147,11 +150,13 @@ async def handle_explore(tool_input: dict[str, Any], ctx: Ctx) -> str:
147
150
  return "Error: explore requires a non-empty 'prompt'."
148
151
 
149
152
  try:
153
+ # No ``model=``: the backend forces a fast model for explore rows
154
+ # (see CliContextBuilder) — passing the parent's model here would
155
+ # only make the row lie about what actually ran.
150
156
  sub_chat_id = await create_chat(
151
157
  ctx.http,
152
158
  folder=str(Path.cwd()),
153
159
  name=f"explore: {description}",
154
- model=ctx.state.model,
155
160
  surface="explore",
156
161
  )
157
162
  except TransportError as e:
@@ -40,8 +40,27 @@ if TYPE_CHECKING:
40
40
  #: starts with one and has no shell metacharacters. Conservative on
41
41
  #: purpose: extra confirmations are mild friction; an unintended write
42
42
  #: is a real outage. ``find`` is intentionally absent — it has
43
- #: ``-delete`` and ``-exec``.
44
- _SAFE_BINARIES: frozenset[str] = frozenset({"ls", "cat"})
43
+ #: ``-delete`` and ``-exec``. ``grep``/``rg`` are absent too — their
44
+ #: matched-text output can smuggle metacharacters and they are better
45
+ #: served by the dedicated ``grep`` tool.
46
+ _SAFE_BINARIES: frozenset[str] = frozenset(
47
+ {"ls", "cat", "head", "tail", "wc", "file", "du", "tree", "stat", "pwd"},
48
+ )
49
+
50
+ #: Read-only ``git`` subcommands. ``branch`` is absent (``git branch -D``
51
+ #: deletes); history / inspection is what read-only usage actually needs.
52
+ _SAFE_GIT_SUBCOMMANDS: frozenset[str] = frozenset({
53
+ "log",
54
+ "show",
55
+ "diff",
56
+ "blame",
57
+ "status",
58
+ "shortlog",
59
+ "ls-files",
60
+ "rev-parse",
61
+ "describe",
62
+ "grep",
63
+ })
45
64
 
46
65
  #: Tokens that can turn a read into a write (redirection / chaining /
47
66
  #: substitution). Their presence anywhere in the command forces a
@@ -55,14 +74,23 @@ _OUTPUT_LIMIT = 8_000
55
74
  def is_safe_command(command: str) -> bool:
56
75
  """True iff ``command`` may run without prompting the user.
57
76
 
58
- Allowlist-based: any token that could redirect / chain / substitute
59
- fails closed (we ask), and only the bare safe binaries pass.
77
+ Allowlist-based and fails closed: any token that could redirect /
78
+ chain / substitute is rejected, ``--output`` (which makes ``git
79
+ log``/``diff`` write a file) is rejected, git global flags (``git -c
80
+ core.pager=… log`` would execute the pager) are rejected, and only
81
+ bare read-only binaries / git subcommands pass.
60
82
  """
61
83
  if any(tok in command for tok in _UNSAFE_TOKENS):
62
84
  return False
63
- parts = command.strip().split(maxsplit=1)
85
+ parts = command.split()
64
86
  if not parts:
65
87
  return False
88
+ if any(p.startswith("--output") for p in parts):
89
+ return False
90
+ if parts[0] == "git":
91
+ # The subcommand must come immediately after ``git`` — global
92
+ # flags (-c, -C, --exec-path…) are rejected wholesale.
93
+ return len(parts) > 1 and parts[1] in _SAFE_GIT_SUBCOMMANDS
66
94
  return parts[0] in _SAFE_BINARIES
67
95
 
68
96