deepy-cli 0.1.6__tar.gz → 0.1.7__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 (71) hide show
  1. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/PKG-INFO +2 -2
  2. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/README.md +1 -1
  3. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/pyproject.toml +1 -1
  4. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/__init__.py +1 -1
  5. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/data/tools/WebFetch.md +2 -1
  6. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/sessions/jsonl.py +39 -7
  7. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/tools/builtin.py +69 -11
  8. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/message_view.py +116 -29
  9. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/styles.py +14 -17
  10. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/terminal.py +21 -2
  11. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/__main__.py +0 -0
  12. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/cli.py +0 -0
  13. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/config/__init__.py +0 -0
  14. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/config/settings.py +0 -0
  15. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/data/__init__.py +0 -0
  16. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/data/tools/AskUserQuestion.md +0 -0
  17. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/data/tools/WebSearch.md +0 -0
  18. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/data/tools/__init__.py +0 -0
  19. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/data/tools/edit.md +0 -0
  20. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/data/tools/modify.md +0 -0
  21. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/data/tools/read.md +0 -0
  22. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/data/tools/shell.md +0 -0
  23. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/data/tools/write.md +0 -0
  24. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/errors.py +0 -0
  25. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/llm/__init__.py +0 -0
  26. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/llm/agent.py +0 -0
  27. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/llm/compaction.py +0 -0
  28. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/llm/context.py +0 -0
  29. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/llm/events.py +0 -0
  30. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/llm/model_capabilities.py +0 -0
  31. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/llm/provider.py +0 -0
  32. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/llm/replay.py +0 -0
  33. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/llm/runner.py +0 -0
  34. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/llm/thinking.py +0 -0
  35. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/prompts/__init__.py +0 -0
  36. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/prompts/compact.py +0 -0
  37. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/prompts/rules.py +0 -0
  38. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/prompts/runtime_context.py +0 -0
  39. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/prompts/system.py +0 -0
  40. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/prompts/tool_docs.py +0 -0
  41. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/sessions/__init__.py +0 -0
  42. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/sessions/manager.py +0 -0
  43. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/skills.py +0 -0
  44. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/status.py +0 -0
  45. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/tools/__init__.py +0 -0
  46. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/tools/agents.py +0 -0
  47. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/tools/file_state.py +0 -0
  48. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/tools/result.py +0 -0
  49. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/tools/shell_utils.py +0 -0
  50. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/__init__.py +0 -0
  51. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/app.py +0 -0
  52. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/ask_user_question.py +0 -0
  53. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/exit_summary.py +0 -0
  54. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/loading_text.py +0 -0
  55. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/markdown.py +0 -0
  56. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/model_picker.py +0 -0
  57. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/prompt_buffer.py +0 -0
  58. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/prompt_input.py +0 -0
  59. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/session_list.py +0 -0
  60. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/session_picker.py +0 -0
  61. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/slash_commands.py +0 -0
  62. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/theme_picker.py +0 -0
  63. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/thinking_state.py +0 -0
  64. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/ui/welcome.py +0 -0
  65. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/update_check.py +0 -0
  66. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/usage.py +0 -0
  67. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/utils/__init__.py +0 -0
  68. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/utils/debug_logger.py +0 -0
  69. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/utils/error_logger.py +0 -0
  70. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/utils/json.py +0 -0
  71. {deepy_cli-0.1.6 → deepy_cli-0.1.7}/src/deepy/utils/notify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: deepy-cli
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Deepy - Vibe coding for DeepSeek models in your terminal
5
5
  Keywords: deepseek,coding-agent,terminal,cli,agents
6
6
  Author: kirineko
@@ -238,5 +238,5 @@ assets live outside the package directory and are not included in the wheel.
238
238
 
239
239
  ## Release Status
240
240
 
241
- Deepy `0.1.6` is released through GitHub and PyPI. Standalone binaries and npm
241
+ Deepy `0.1.7` is released through GitHub and PyPI. Standalone binaries and npm
242
242
  wrappers can be added later, but the primary distribution is the Python CLI.
@@ -210,5 +210,5 @@ assets live outside the package directory and are not included in the wheel.
210
210
 
211
211
  ## Release Status
212
212
 
213
- Deepy `0.1.6` is released through GitHub and PyPI. Standalone binaries and npm
213
+ Deepy `0.1.7` is released through GitHub and PyPI. Standalone binaries and npm
214
214
  wrappers can be added later, but the primary distribution is the Python CLI.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepy-cli"
3
- version = "0.1.6"
3
+ version = "0.1.7"
4
4
  description = "Deepy - Vibe coding for DeepSeek models in your terminal"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.1.6"
3
+ __version__ = "0.1.7"
4
4
 
5
5
 
6
6
  def main() -> None:
@@ -5,5 +5,6 @@ Fetch a specific web page when the user provides a complete URL.
5
5
  Args: `url`.
6
6
 
7
7
  Accepts only complete `http://` or `https://` URLs. Returns the final URL, title,
8
- content type, and extracted readable text for HTML pages. Use `WebSearch` to
8
+ content type, and extracted readable text for HTML pages, including standard
9
+ description metadata when ordinary body text is unavailable. Use `WebSearch` to
9
10
  discover URLs; use `WebFetch` when the URL is already known.
@@ -13,6 +13,8 @@ from deepy.utils import json as json_utils
13
13
 
14
14
  SESSION_INDEX_VERSION = 2
15
15
  MAX_SESSION_INDEX_ENTRIES = 50
16
+ CONTEXT_UNDERCOUNT_REPAIR_RATIO = 2
17
+ CONTEXT_UNDERCOUNT_REPAIR_MIN_DELTA = 128
16
18
 
17
19
 
18
20
  @dataclass(frozen=True)
@@ -147,11 +149,13 @@ class DeepyJsonlSession:
147
149
  return
148
150
  previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
149
151
  accumulated = merge_usage(previous.get("usage") if previous else None, normalized)
152
+ current_state = self.context_token_state()
153
+ checkpoint_tokens = max(normalized.prompt_tokens, current_state.active_tokens)
150
154
  record_count = len(self._load_records())
151
155
  self._touch_index(
152
- active_tokens=normalized.prompt_tokens,
156
+ active_tokens=checkpoint_tokens,
153
157
  usage=accumulated.to_dict(),
154
- last_usage_tokens=normalized.prompt_tokens,
158
+ last_usage_tokens=checkpoint_tokens,
155
159
  pending_tokens=0,
156
160
  last_usage_record_count=record_count,
157
161
  )
@@ -166,13 +170,27 @@ class DeepyJsonlSession:
166
170
  last_usage_tokens = _optional_int(previous.get("lastUsageTokens"))
167
171
  last_usage_record_count = _optional_int(previous.get("lastUsageRecordCount"))
168
172
  if last_usage_tokens is not None and last_usage_record_count is not None:
169
- bounded_count = max(0, min(last_usage_record_count, len(source)))
170
- pending_tokens = sum(_estimate_record_tokens(record) for record in source[bounded_count:])
173
+ if last_usage_record_count > len(source):
174
+ active_tokens = self._estimate_active_tokens(source)
175
+ return ContextTokenState(
176
+ active_tokens=active_tokens,
177
+ last_usage_tokens=None,
178
+ pending_tokens=0,
179
+ last_usage_record_count=None,
180
+ estimated=True,
181
+ )
182
+ pending_tokens = sum(_estimate_record_tokens(record) for record in source[last_usage_record_count:])
183
+ checkpoint_tokens = last_usage_tokens + pending_tokens
184
+ estimated_tokens = self._estimate_active_tokens(source)
185
+ active_tokens = _repair_undercounted_context_tokens(
186
+ checkpoint_tokens,
187
+ estimated_tokens,
188
+ )
171
189
  return ContextTokenState(
172
- active_tokens=last_usage_tokens + pending_tokens,
190
+ active_tokens=active_tokens,
173
191
  last_usage_tokens=last_usage_tokens,
174
- pending_tokens=pending_tokens,
175
- last_usage_record_count=bounded_count,
192
+ pending_tokens=max(active_tokens - last_usage_tokens, pending_tokens),
193
+ last_usage_record_count=last_usage_record_count,
176
194
  estimated=True,
177
195
  )
178
196
  active_tokens = self._estimate_active_tokens(source)
@@ -348,6 +366,9 @@ class DeepyJsonlSession:
348
366
 
349
367
  @dataclass(frozen=True)
350
368
  class ContextTokenState:
369
+ # active_tokens is the effective context pressure used by UI and auto compaction.
370
+ # last_usage_tokens is the latest checkpoint covered by usage or session rewrite.
371
+ # pending_tokens estimates records appended after that checkpoint.
351
372
  active_tokens: int
352
373
  last_usage_tokens: int | None = None
353
374
  pending_tokens: int = 0
@@ -452,3 +473,14 @@ def _estimate_record_tokens(record: dict[str, Any]) -> int:
452
473
  if item is not None:
453
474
  return estimate_tokens_for_item(item)
454
475
  return estimate_tokens_for_item(record.get("content", ""))
476
+
477
+
478
+ def _repair_undercounted_context_tokens(checkpoint_tokens: int, estimated_tokens: int) -> int:
479
+ if estimated_tokens <= checkpoint_tokens:
480
+ return checkpoint_tokens
481
+ if (
482
+ estimated_tokens - checkpoint_tokens >= CONTEXT_UNDERCOUNT_REPAIR_MIN_DELTA
483
+ and estimated_tokens >= checkpoint_tokens * CONTEXT_UNDERCOUNT_REPAIR_RATIO
484
+ ):
485
+ return estimated_tokens
486
+ return checkpoint_tokens
@@ -38,6 +38,7 @@ MAX_BASH_OUTPUT_CHARS = 30_000
38
38
  MAX_BASH_CAPTURE_CHARS = 10 * 1024 * 1024
39
39
  MAX_WEB_FETCH_BYTES = 2 * 1024 * 1024
40
40
  MAX_WEB_FETCH_OUTPUT_CHARS = 30_000
41
+ MIN_USEFUL_WEB_FETCH_BODY_CHARS = 40
41
42
  DEFAULT_WEB_SEARCH_URL = "https://html.duckduckgo.com/html/"
42
43
  DEFAULT_WEB_SEARCH_RESULTS = 8
43
44
  MAX_WEB_SEARCH_CALLS_PER_TURN = 8
@@ -942,12 +943,15 @@ class _ReadableHtmlParser(HTMLParser):
942
943
  super().__init__(convert_charrefs=True)
943
944
  self.title_parts: list[str] = []
944
945
  self.text_parts: list[str] = []
946
+ self.description_candidates: dict[str, str] = {}
945
947
  self._in_title = False
946
948
  self._skip_depth = 0
947
949
 
948
950
  def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
949
- del attrs
950
951
  normalized = tag.lower()
952
+ if normalized == "meta":
953
+ self._record_meta_description(attrs)
954
+ return
951
955
  if normalized in self.SKIP_TAGS:
952
956
  self._skip_depth += 1
953
957
  return
@@ -994,6 +998,27 @@ class _ReadableHtmlParser(HTMLParser):
994
998
  raw = re.sub(r"\n{3,}", "\n\n", raw)
995
999
  return "\n".join(line.strip() for line in raw.splitlines()).strip()
996
1000
 
1001
+ @property
1002
+ def meta_description(self) -> str:
1003
+ for key in ("description", "og:description", "twitter:description"):
1004
+ text = self.description_candidates.get(key, "").strip()
1005
+ if text:
1006
+ return text
1007
+ return ""
1008
+
1009
+ def _record_meta_description(self, attrs: list[tuple[str, str | None]]) -> None:
1010
+ attr_map = {name.lower(): value for name, value in attrs if value is not None}
1011
+ raw_key = attr_map.get("name") or attr_map.get("property")
1012
+ content = attr_map.get("content", "")
1013
+ if not raw_key or not content:
1014
+ return
1015
+ key = raw_key.strip().lower()
1016
+ if key not in {"description", "og:description", "twitter:description"}:
1017
+ return
1018
+ normalized = " ".join(content.split()).strip()
1019
+ if normalized and key not in self.description_candidates:
1020
+ self.description_candidates[key] = normalized
1021
+
997
1022
 
998
1023
  def _validate_web_fetch_url(url: str) -> tuple[str | None, str | None]:
999
1024
  stripped = url.strip()
@@ -1016,11 +1041,31 @@ def _is_html_response(content_type: str, text: str) -> bool:
1016
1041
  return "<html" in prefix or "<!doctype html" in prefix
1017
1042
 
1018
1043
 
1019
- def _extract_readable_html(html: str) -> tuple[str, str]:
1044
+ def _extract_readable_html(html: str) -> tuple[str, str, str]:
1020
1045
  parser = _ReadableHtmlParser()
1021
1046
  parser.feed(html)
1022
1047
  parser.close()
1023
- return parser.title, parser.readable_text
1048
+ return parser.title, parser.readable_text, parser.meta_description
1049
+
1050
+
1051
+ def _select_web_fetch_html_text(readable_text: str, metadata_text: str) -> str:
1052
+ stripped = readable_text.strip()
1053
+ if stripped and _is_useful_web_fetch_body_text(stripped):
1054
+ return stripped
1055
+ return metadata_text.strip() or stripped
1056
+
1057
+
1058
+ def _is_useful_web_fetch_body_text(text: str) -> bool:
1059
+ normalized = " ".join(text.split()).strip().lower()
1060
+ if len(normalized) >= MIN_USEFUL_WEB_FETCH_BODY_CHARS:
1061
+ return True
1062
+ return normalized not in {
1063
+ "",
1064
+ "loading",
1065
+ "loading...",
1066
+ "please enable javascript",
1067
+ "you need to enable javascript to run this app.",
1068
+ }
1024
1069
 
1025
1070
 
1026
1071
  def _format_web_fetch_output(
@@ -1473,18 +1518,16 @@ class ToolRuntime:
1473
1518
  request = urllib.request.Request(
1474
1519
  target_url,
1475
1520
  headers={
1476
- "User-Agent": (
1477
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
1478
- "AppleWebKit/537.36 (KHTML, like Gecko) Deepy/0.1"
1479
- ),
1480
- "Accept": "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8",
1521
+ **WEB_SEARCH_BROWSER_HEADERS,
1522
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7",
1481
1523
  },
1482
1524
  method="GET",
1483
1525
  )
1484
1526
  try:
1485
1527
  with urllib.request.urlopen(request, timeout=30) as response:
1486
1528
  final_url = response.geturl()
1487
- content_type = response.headers.get("Content-Type", "")
1529
+ content_type = _response_header(response, "Content-Type") or ""
1530
+ content_encoding = _response_header(response, "Content-Encoding")
1488
1531
  body = response.read(MAX_WEB_FETCH_BYTES + 1)
1489
1532
  except Exception as exc:
1490
1533
  return ToolResult.error_result(
@@ -1501,9 +1544,24 @@ class ToolRuntime:
1501
1544
  bytes_truncated = len(body) > MAX_WEB_FETCH_BYTES
1502
1545
  body = body[:MAX_WEB_FETCH_BYTES]
1503
1546
  charset = _charset_from_content_type(content_type)
1504
- decoded = body.decode(charset, errors="replace")
1547
+ try:
1548
+ decoded = _decode_http_body(body, encoding=content_encoding, charset=charset)
1549
+ except Exception as exc:
1550
+ return ToolResult.error_result(
1551
+ name,
1552
+ f"WebFetch response decode failed: {exc}",
1553
+ metadata={
1554
+ "url": target_url,
1555
+ "finalUrl": final_url,
1556
+ "contentType": content_type,
1557
+ "contentEncoding": content_encoding,
1558
+ "charset": charset,
1559
+ "activityLabel": activity_label,
1560
+ },
1561
+ ).to_json()
1505
1562
  if _is_html_response(content_type, decoded):
1506
- title, readable_text = _extract_readable_html(decoded)
1563
+ title, readable_text, metadata_text = _extract_readable_html(decoded)
1564
+ readable_text = _select_web_fetch_html_text(readable_text, metadata_text)
1507
1565
  else:
1508
1566
  title = ""
1509
1567
  readable_text = decoded.strip()
@@ -4,8 +4,11 @@ import re
4
4
  from dataclasses import dataclass
5
5
  from typing import Any
6
6
 
7
+ from rich.cells import cell_len
7
8
  from rich.console import Group
8
9
  from rich.panel import Panel
10
+ from rich.style import Style
11
+ from rich.syntax import Syntax
9
12
  from rich.text import Text
10
13
 
11
14
  from deepy.utils import json as json_utils
@@ -19,6 +22,8 @@ from deepy.ui.styles import (
19
22
  MAX_SUMMARY_CHARS = 160
20
23
  MAX_THINKING_SUMMARY_CHARS = 360
21
24
  MAX_DIFF_LINES = 80
25
+ MAX_SYNTAX_SAMPLE_CHARS = 4_000
26
+ MAX_SYNTAX_SAMPLE_LINES = 80
22
27
  DIFF_PREVIEW_TOOLS = {"edit", "write"}
23
28
  ROLE_TITLES = {
24
29
  "user": "You",
@@ -146,6 +151,7 @@ def render_tool_diff_preview(
146
151
  *,
147
152
  max_lines: int = MAX_DIFF_LINES,
148
153
  palette: UiPalette | None = None,
154
+ width: int | None = None,
149
155
  ) -> Group | None:
150
156
  palette = palette or DARK_PALETTE
151
157
  view = parse_tool_output(output)
@@ -158,14 +164,11 @@ def render_tool_diff_preview(
158
164
  preview = parse_diff_preview_view(diff, path=view.path)
159
165
  if not preview.lines:
160
166
  return None
161
- if view.name.lower() == "write":
162
- return Group(
163
- render_diff_preview_header(preview, label="Wrote", palette=palette),
164
- *(render_write_preview_line(line, palette=palette) for line in preview.lines),
165
- )
167
+ syntax = _diff_preview_syntax(preview, palette)
168
+ label = "Wrote" if view.name.lower() == "write" else "Edited"
166
169
  return Group(
167
- render_diff_preview_header(preview, label="Edited", palette=palette),
168
- *(render_diff_preview_line(line, palette=palette) for line in preview.lines),
170
+ render_diff_preview_header(preview, label=label, palette=palette),
171
+ *(render_diff_preview_line(line, palette=palette, width=width, syntax=syntax) for line in preview.lines),
169
172
  )
170
173
 
171
174
 
@@ -192,20 +195,36 @@ def render_diff_preview_header(
192
195
  return Text(f"• {label}", style=f"bold {palette.info}")
193
196
 
194
197
 
195
- def render_diff_preview_line(line: DiffPreviewLine, *, palette: UiPalette | None = None) -> Text:
198
+ def render_diff_preview_line(
199
+ line: DiffPreviewLine,
200
+ *,
201
+ palette: UiPalette | None = None,
202
+ width: int | None = None,
203
+ syntax: Syntax | None = None,
204
+ ) -> Text:
196
205
  palette = palette or DARK_PALETTE
197
206
  content = line.content if line.content else " "
198
207
  old_lineno = _line_number_text(line.old_lineno)
199
208
  new_lineno = _line_number_text(line.new_lineno)
200
209
  if line.kind == "added":
201
- return Text.assemble(
202
- (f"{old_lineno} {new_lineno} + ", palette.diff_added_gutter),
203
- (content, palette.diff_added),
210
+ return _pad_changed_diff_line(
211
+ Text.assemble(
212
+ (f"{old_lineno} {new_lineno} ", palette.diff_added_gutter),
213
+ ("+ ", palette.diff_added_marker),
214
+ _highlight_diff_content(content, syntax=syntax, style=palette.diff_added),
215
+ ),
216
+ width=width,
217
+ style=palette.diff_added,
204
218
  )
205
219
  if line.kind == "removed":
206
- return Text.assemble(
207
- (f"{old_lineno} {new_lineno} - ", palette.diff_removed_gutter),
208
- (content, palette.diff_removed),
220
+ return _pad_changed_diff_line(
221
+ Text.assemble(
222
+ (f"{old_lineno} {new_lineno} ", palette.diff_removed_gutter),
223
+ ("- ", palette.diff_removed_marker),
224
+ _highlight_diff_content(content, syntax=syntax, style=palette.diff_removed),
225
+ ),
226
+ width=width,
227
+ style=palette.diff_removed,
209
228
  )
210
229
  return Text.assemble(
211
230
  (f"{old_lineno} {new_lineno} ", palette.diff_context),
@@ -213,18 +232,80 @@ def render_diff_preview_line(line: DiffPreviewLine, *, palette: UiPalette | None
213
232
  )
214
233
 
215
234
 
216
- def render_write_preview_line(line: DiffPreviewLine, *, palette: UiPalette | None = None) -> Text:
217
- palette = palette or DARK_PALETTE
218
- content = line.content if line.content else " "
219
- lineno = line.new_lineno if line.new_lineno is not None else line.old_lineno
220
- marker = "-" if line.kind == "removed" else " "
221
- gutter_style = palette.diff_removed_gutter if line.kind == "removed" else palette.write_preview_gutter
222
- content_style = (
223
- palette.write_preview_removed if line.kind == "removed" else palette.write_preview_content
224
- )
225
- return Text.assemble(
226
- (f"{_line_number_text(lineno)} {marker} ", gutter_style),
227
- (content, content_style),
235
+ def _pad_changed_diff_line(text: Text, *, width: int | None, style: str) -> Text:
236
+ if width is None or width <= 0:
237
+ return text
238
+ padding = width - cell_len(text.plain)
239
+ if padding > 0:
240
+ text.append(" " * padding, style=style)
241
+ return text
242
+
243
+
244
+ def _diff_preview_syntax(preview: DiffPreview, palette: UiPalette) -> Syntax | None:
245
+ lexer = _guess_diff_preview_lexer(preview)
246
+ if lexer is None:
247
+ return None
248
+ try:
249
+ return Syntax("", lexer, theme=_diff_syntax_theme(palette), line_numbers=False)
250
+ except Exception:
251
+ return None
252
+
253
+
254
+ def _guess_diff_preview_lexer(preview: DiffPreview) -> str | None:
255
+ if not preview.path:
256
+ return None
257
+ sample_lines: list[str] = []
258
+ sample_size = 0
259
+ for line in preview.lines:
260
+ if line.kind not in {"added", "removed", "context"} or not line.content.strip():
261
+ continue
262
+ sample_lines.append(line.content)
263
+ sample_size += len(line.content) + 1
264
+ if len(sample_lines) >= MAX_SYNTAX_SAMPLE_LINES or sample_size >= MAX_SYNTAX_SAMPLE_CHARS:
265
+ break
266
+ sample = "\n".join(sample_lines)
267
+ if not sample.strip():
268
+ return None
269
+ try:
270
+ lexer = Syntax.guess_lexer(preview.path, sample)
271
+ except Exception:
272
+ return None
273
+ return lexer if lexer and lexer != "default" else None
274
+
275
+
276
+ def _highlight_diff_content(
277
+ content: str,
278
+ *,
279
+ syntax: Syntax | None,
280
+ style: str,
281
+ ) -> Text:
282
+ if syntax is None or not content.strip():
283
+ return Text(content, style=style)
284
+ try:
285
+ highlighted = syntax.highlight(content)
286
+ except Exception:
287
+ return Text(content, style=style)
288
+
289
+ base = Style.parse(style)
290
+ text = Text(content, style=base)
291
+ for span in highlighted.spans:
292
+ text.stylize(_syntax_style_on_diff_background(span.style, base), span.start, span.end)
293
+ return text
294
+
295
+
296
+ def _diff_syntax_theme(palette: UiPalette) -> str:
297
+ return "default" if palette.name == "light" else "monokai"
298
+
299
+
300
+ def _syntax_style_on_diff_background(style: Style, base: Style) -> Style:
301
+ return Style(
302
+ color=style.color or base.color,
303
+ bgcolor=base.bgcolor,
304
+ bold=style.bold,
305
+ italic=style.italic,
306
+ underline=style.underline,
307
+ dim=style.dim,
308
+ strike=style.strike,
228
309
  )
229
310
 
230
311
 
@@ -342,11 +423,16 @@ def is_invisible_execution(content: str) -> bool:
342
423
  return isinstance(parsed, dict) and parsed.get("name") == "shell" and parsed.get("ok") is not True
343
424
 
344
425
 
345
- def render_tool_output(output: str, *, palette: UiPalette | None = None) -> Group:
426
+ def render_tool_output(
427
+ output: str,
428
+ *,
429
+ palette: UiPalette | None = None,
430
+ width: int | None = None,
431
+ ) -> Group:
346
432
  palette = palette or DARK_PALETTE
347
433
  view = parse_tool_output(output)
348
434
  parts: list[Any] = [Text(view.summary, style=status_style(view.ok, palette))]
349
- diff = render_tool_diff_preview(output, palette=palette)
435
+ diff = render_tool_diff_preview(output, palette=palette, width=width)
350
436
  if diff:
351
437
  parts.append(diff)
352
438
  return Group(*parts)
@@ -357,12 +443,13 @@ def render_message(
357
443
  *,
358
444
  project_root: str | None = None,
359
445
  palette: UiPalette | None = None,
446
+ width: int | None = None,
360
447
  ) -> Any:
361
448
  palette = palette or DARK_PALETTE
362
449
  role = _string_or_default(message.get("role"), "message")
363
450
  content = _message_content_text(message.get("content"))
364
451
  if role == "tool":
365
- return render_tool_output(content, palette=palette)
452
+ return render_tool_output(content, palette=palette, width=width)
366
453
 
367
454
  title = ROLE_TITLES.get(role, role.title())
368
455
  if role == "assistant":
@@ -32,12 +32,11 @@ class UiPalette:
32
32
  panel_border: str
33
33
  diff_added: str
34
34
  diff_added_gutter: str
35
+ diff_added_marker: str
35
36
  diff_removed: str
36
37
  diff_removed_gutter: str
38
+ diff_removed_marker: str
37
39
  diff_context: str
38
- write_preview_gutter: str
39
- write_preview_content: str
40
- write_preview_removed: str
41
40
  prompt: str
42
41
  placeholder: str
43
42
  toolbar_background: str
@@ -69,14 +68,13 @@ DARK_PALETTE = UiPalette(
69
68
  system=STYLE_SYSTEM,
70
69
  tool=STYLE_TOOL,
71
70
  panel_border=STYLE_INFO,
72
- diff_added="#e5e7eb on #14532d",
73
- diff_added_gutter="#cbd5e1 on #14532d",
74
- diff_removed="#e5e7eb on #7f1d1d",
75
- diff_removed_gutter="#cbd5e1 on #7f1d1d",
71
+ diff_added="#e5e7eb on #1f3d2b",
72
+ diff_added_gutter="#cbd5e1 on #1f3d2b",
73
+ diff_added_marker="bold #86efac on #1f3d2b",
74
+ diff_removed="#e5e7eb on #4a2528",
75
+ diff_removed_gutter="#cbd5e1 on #4a2528",
76
+ diff_removed_marker="bold #fca5a5 on #4a2528",
76
77
  diff_context=STYLE_MUTED,
77
- write_preview_gutter="#94a3b8 on #1f2937",
78
- write_preview_content="#d7def8 on #1f2937",
79
- write_preview_removed="#fecaca on #7f1d1d",
80
78
  prompt="ansicyan bold",
81
79
  placeholder="#8a90aa",
82
80
  toolbar_background="#161821",
@@ -108,14 +106,13 @@ LIGHT_PALETTE = UiPalette(
108
106
  system="#7e22ce",
109
107
  tool="#92400e",
110
108
  panel_border="#2563eb",
111
- diff_added="#064e3b on #dcfce7",
112
- diff_added_gutter="#065f46 on #bbf7d0",
113
- diff_removed="#7f1d1d on #fee2e2",
114
- diff_removed_gutter="#991b1b on #fecaca",
109
+ diff_added="#064e3b on #ecfdf5",
110
+ diff_added_gutter="#065f46 on #d1fae5",
111
+ diff_added_marker="bold #047857 on #d1fae5",
112
+ diff_removed="#7f1d1d on #fef2f2",
113
+ diff_removed_gutter="#991b1b on #fee2e2",
114
+ diff_removed_marker="bold #b91c1c on #fee2e2",
115
115
  diff_context="#374151",
116
- write_preview_gutter="#475569 on #e2e8f0",
117
- write_preview_content="#111827 on #f8fafc",
118
- write_preview_removed="#7f1d1d on #fee2e2",
119
116
  prompt="#0369a1 bold",
120
117
  placeholder="#64748b",
121
118
  toolbar_background="#e2e8f0",
@@ -1328,7 +1328,7 @@ def _format_context_footer(
1328
1328
  return " · ".join(parts)
1329
1329
 
1330
1330
  session_entry = _session_entry(project_root, session_id)
1331
- used_tokens = session_entry.active_tokens if session_entry is not None else (0 if not session_id else None)
1331
+ used_tokens = _session_context_tokens(project_root, session_id, session_entry)
1332
1332
  used_text = _format_token_count_short(used_tokens) if used_tokens is not None else "unknown"
1333
1333
  used_ratio = (
1334
1334
  f" ({used_tokens / window_tokens * 100:.1f}%)"
@@ -1372,6 +1372,25 @@ def _session_entry(project_root: Path | None, session_id: str | None) -> Session
1372
1372
  return entry
1373
1373
 
1374
1374
 
1375
+ def _session_context_tokens(
1376
+ project_root: Path | None,
1377
+ session_id: str | None,
1378
+ session_entry: SessionEntry | None,
1379
+ ) -> int | None:
1380
+ if not session_id:
1381
+ return 0
1382
+ if project_root is not None:
1383
+ try:
1384
+ session = DeepyJsonlSession.open(project_root, session_id)
1385
+ if session.path.exists():
1386
+ return session.context_token_state().active_tokens
1387
+ except Exception:
1388
+ pass
1389
+ if session_entry is not None:
1390
+ return session_entry.active_tokens
1391
+ return None
1392
+
1393
+
1375
1394
  def _format_token_count_short(value: int) -> str:
1376
1395
  if value < 1_000:
1377
1396
  return str(value)
@@ -1500,7 +1519,7 @@ def _print_stream_event(
1500
1519
  call_summary = call.summary if call is not None else view.name
1501
1520
  summary = format_tool_progress_summary(call_summary, event.text)
1502
1521
  console.print(_status_line(summary, status_style(view.ok, palette)))
1503
- diff = render_tool_diff_preview(event.text, palette=palette)
1522
+ diff = render_tool_diff_preview(event.text, palette=palette, width=console.width)
1504
1523
  if diff:
1505
1524
  console.print(diff)
1506
1525
  return
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes