deepy-cli 0.1.6__tar.gz → 0.1.8__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.8}/PKG-INFO +2 -2
  2. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/README.md +1 -1
  3. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/pyproject.toml +1 -1
  4. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/__init__.py +1 -1
  5. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/data/tools/WebFetch.md +2 -1
  6. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/data/tools/shell.md +4 -0
  7. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/sessions/jsonl.py +39 -7
  8. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/tools/builtin.py +112 -15
  9. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/message_view.py +116 -29
  10. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/prompt_input.py +56 -0
  11. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/styles.py +14 -17
  12. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/terminal.py +21 -2
  13. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/__main__.py +0 -0
  14. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/cli.py +0 -0
  15. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/config/__init__.py +0 -0
  16. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/config/settings.py +0 -0
  17. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/data/__init__.py +0 -0
  18. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/data/tools/AskUserQuestion.md +0 -0
  19. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/data/tools/WebSearch.md +0 -0
  20. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/data/tools/__init__.py +0 -0
  21. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/data/tools/edit.md +0 -0
  22. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/data/tools/modify.md +0 -0
  23. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/data/tools/read.md +0 -0
  24. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/data/tools/write.md +0 -0
  25. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/errors.py +0 -0
  26. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/llm/__init__.py +0 -0
  27. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/llm/agent.py +0 -0
  28. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/llm/compaction.py +0 -0
  29. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/llm/context.py +0 -0
  30. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/llm/events.py +0 -0
  31. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/llm/model_capabilities.py +0 -0
  32. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/llm/provider.py +0 -0
  33. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/llm/replay.py +0 -0
  34. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/llm/runner.py +0 -0
  35. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/llm/thinking.py +0 -0
  36. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/prompts/__init__.py +0 -0
  37. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/prompts/compact.py +0 -0
  38. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/prompts/rules.py +0 -0
  39. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/prompts/runtime_context.py +0 -0
  40. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/prompts/system.py +0 -0
  41. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/prompts/tool_docs.py +0 -0
  42. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/sessions/__init__.py +0 -0
  43. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/sessions/manager.py +0 -0
  44. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/skills.py +0 -0
  45. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/status.py +0 -0
  46. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/tools/__init__.py +0 -0
  47. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/tools/agents.py +0 -0
  48. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/tools/file_state.py +0 -0
  49. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/tools/result.py +0 -0
  50. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/tools/shell_utils.py +0 -0
  51. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/__init__.py +0 -0
  52. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/app.py +0 -0
  53. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/ask_user_question.py +0 -0
  54. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/exit_summary.py +0 -0
  55. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/loading_text.py +0 -0
  56. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/markdown.py +0 -0
  57. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/model_picker.py +0 -0
  58. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/prompt_buffer.py +0 -0
  59. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/session_list.py +0 -0
  60. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/session_picker.py +0 -0
  61. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/slash_commands.py +0 -0
  62. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/theme_picker.py +0 -0
  63. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/thinking_state.py +0 -0
  64. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/ui/welcome.py +0 -0
  65. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/update_check.py +0 -0
  66. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/usage.py +0 -0
  67. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/utils/__init__.py +0 -0
  68. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/utils/debug_logger.py +0 -0
  69. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/utils/error_logger.py +0 -0
  70. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/src/deepy/utils/json.py +0 -0
  71. {deepy_cli-0.1.6 → deepy_cli-0.1.8}/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.8
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.8` 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.8` 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.8"
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.8"
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.
@@ -9,5 +9,9 @@ Use the runtime context's command dialect and path style: PowerShell uses
9
9
  PowerShell commands and Windows paths, `cmd` uses cmd syntax, and `posix` uses
10
10
  POSIX shell syntax.
11
11
 
12
+ On Windows PowerShell, Python child processes run with UTF-8 I/O defaults for
13
+ the command invocation; do not ask users to run `chcp` or change their
14
+ PowerShell profile for Unicode output.
15
+
12
16
  Runs in the session cwd, preserves cwd between calls when supported, and returns
13
17
  stdout/stderr JSON with cwd, exit-code, and shell metadata.
@@ -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
@@ -207,6 +208,7 @@ class ShellInvocation:
207
208
  shell_path: str
208
209
  args: list[str]
209
210
  runtime_environment: RuntimeEnvironment
211
+ env: dict[str, str] | None = None
210
212
 
211
213
 
212
214
  def _find_occurrences(text: str, needle: str, scope: tuple[int, int]) -> list[MatchOccurrence]:
@@ -942,12 +944,15 @@ class _ReadableHtmlParser(HTMLParser):
942
944
  super().__init__(convert_charrefs=True)
943
945
  self.title_parts: list[str] = []
944
946
  self.text_parts: list[str] = []
947
+ self.description_candidates: dict[str, str] = {}
945
948
  self._in_title = False
946
949
  self._skip_depth = 0
947
950
 
948
951
  def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
949
- del attrs
950
952
  normalized = tag.lower()
953
+ if normalized == "meta":
954
+ self._record_meta_description(attrs)
955
+ return
951
956
  if normalized in self.SKIP_TAGS:
952
957
  self._skip_depth += 1
953
958
  return
@@ -994,6 +999,27 @@ class _ReadableHtmlParser(HTMLParser):
994
999
  raw = re.sub(r"\n{3,}", "\n\n", raw)
995
1000
  return "\n".join(line.strip() for line in raw.splitlines()).strip()
996
1001
 
1002
+ @property
1003
+ def meta_description(self) -> str:
1004
+ for key in ("description", "og:description", "twitter:description"):
1005
+ text = self.description_candidates.get(key, "").strip()
1006
+ if text:
1007
+ return text
1008
+ return ""
1009
+
1010
+ def _record_meta_description(self, attrs: list[tuple[str, str | None]]) -> None:
1011
+ attr_map = {name.lower(): value for name, value in attrs if value is not None}
1012
+ raw_key = attr_map.get("name") or attr_map.get("property")
1013
+ content = attr_map.get("content", "")
1014
+ if not raw_key or not content:
1015
+ return
1016
+ key = raw_key.strip().lower()
1017
+ if key not in {"description", "og:description", "twitter:description"}:
1018
+ return
1019
+ normalized = " ".join(content.split()).strip()
1020
+ if normalized and key not in self.description_candidates:
1021
+ self.description_candidates[key] = normalized
1022
+
997
1023
 
998
1024
  def _validate_web_fetch_url(url: str) -> tuple[str | None, str | None]:
999
1025
  stripped = url.strip()
@@ -1016,11 +1042,31 @@ def _is_html_response(content_type: str, text: str) -> bool:
1016
1042
  return "<html" in prefix or "<!doctype html" in prefix
1017
1043
 
1018
1044
 
1019
- def _extract_readable_html(html: str) -> tuple[str, str]:
1045
+ def _extract_readable_html(html: str) -> tuple[str, str, str]:
1020
1046
  parser = _ReadableHtmlParser()
1021
1047
  parser.feed(html)
1022
1048
  parser.close()
1023
- return parser.title, parser.readable_text
1049
+ return parser.title, parser.readable_text, parser.meta_description
1050
+
1051
+
1052
+ def _select_web_fetch_html_text(readable_text: str, metadata_text: str) -> str:
1053
+ stripped = readable_text.strip()
1054
+ if stripped and _is_useful_web_fetch_body_text(stripped):
1055
+ return stripped
1056
+ return metadata_text.strip() or stripped
1057
+
1058
+
1059
+ def _is_useful_web_fetch_body_text(text: str) -> bool:
1060
+ normalized = " ".join(text.split()).strip().lower()
1061
+ if len(normalized) >= MIN_USEFUL_WEB_FETCH_BODY_CHARS:
1062
+ return True
1063
+ return normalized not in {
1064
+ "",
1065
+ "loading",
1066
+ "loading...",
1067
+ "please enable javascript",
1068
+ "you need to enable javascript to run this app.",
1069
+ }
1024
1070
 
1025
1071
 
1026
1072
  def _format_web_fetch_output(
@@ -1354,6 +1400,7 @@ class ToolRuntime:
1354
1400
  process = subprocess.Popen(
1355
1401
  [shell_invocation.shell_path, *shell_invocation.args],
1356
1402
  cwd=self.cwd,
1403
+ env=shell_invocation.env,
1357
1404
  text=True,
1358
1405
  stdout=stdout_file,
1359
1406
  stderr=stderr_file,
@@ -1473,18 +1520,16 @@ class ToolRuntime:
1473
1520
  request = urllib.request.Request(
1474
1521
  target_url,
1475
1522
  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",
1523
+ **WEB_SEARCH_BROWSER_HEADERS,
1524
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7",
1481
1525
  },
1482
1526
  method="GET",
1483
1527
  )
1484
1528
  try:
1485
1529
  with urllib.request.urlopen(request, timeout=30) as response:
1486
1530
  final_url = response.geturl()
1487
- content_type = response.headers.get("Content-Type", "")
1531
+ content_type = _response_header(response, "Content-Type") or ""
1532
+ content_encoding = _response_header(response, "Content-Encoding")
1488
1533
  body = response.read(MAX_WEB_FETCH_BYTES + 1)
1489
1534
  except Exception as exc:
1490
1535
  return ToolResult.error_result(
@@ -1501,9 +1546,24 @@ class ToolRuntime:
1501
1546
  bytes_truncated = len(body) > MAX_WEB_FETCH_BYTES
1502
1547
  body = body[:MAX_WEB_FETCH_BYTES]
1503
1548
  charset = _charset_from_content_type(content_type)
1504
- decoded = body.decode(charset, errors="replace")
1549
+ try:
1550
+ decoded = _decode_http_body(body, encoding=content_encoding, charset=charset)
1551
+ except Exception as exc:
1552
+ return ToolResult.error_result(
1553
+ name,
1554
+ f"WebFetch response decode failed: {exc}",
1555
+ metadata={
1556
+ "url": target_url,
1557
+ "finalUrl": final_url,
1558
+ "contentType": content_type,
1559
+ "contentEncoding": content_encoding,
1560
+ "charset": charset,
1561
+ "activityLabel": activity_label,
1562
+ },
1563
+ ).to_json()
1505
1564
  if _is_html_response(content_type, decoded):
1506
- title, readable_text = _extract_readable_html(decoded)
1565
+ title, readable_text, metadata_text = _extract_readable_html(decoded)
1566
+ readable_text = _select_web_fetch_html_text(readable_text, metadata_text)
1507
1567
  else:
1508
1568
  title = ""
1509
1569
  readable_text = decoded.strip()
@@ -1692,8 +1752,7 @@ def _read_text_preserving_newlines(path: Path) -> str:
1692
1752
  def _read_text_metadata(path: Path) -> TextFileMetadata:
1693
1753
  data = path.read_bytes()
1694
1754
  encoding = _detect_text_encoding(data)
1695
- python_encoding = "utf-16" if encoding == "utf16le" else "utf-8"
1696
- text = data.decode(python_encoding, errors="replace")
1755
+ text = data.decode(_python_text_encoding(encoding), errors="replace")
1697
1756
  return TextFileMetadata(
1698
1757
  content=text,
1699
1758
  encoding=encoding,
@@ -1704,12 +1763,32 @@ def _read_text_metadata(path: Path) -> TextFileMetadata:
1704
1763
  def _detect_text_encoding(data: bytes) -> str:
1705
1764
  if len(data) >= 2 and data[0] == 0xFF and data[1] == 0xFE:
1706
1765
  return "utf16le"
1766
+ if data.startswith(b"\xef\xbb\xbf"):
1767
+ return "utf8-sig"
1768
+ try:
1769
+ data.decode("utf-8", errors="strict")
1770
+ return "utf8"
1771
+ except UnicodeDecodeError:
1772
+ pass
1773
+ try:
1774
+ data.decode("gb18030", errors="strict")
1775
+ return "gb18030"
1776
+ except UnicodeDecodeError:
1777
+ return "utf8"
1778
+
1779
+
1780
+ def _python_text_encoding(encoding: str) -> str:
1781
+ if encoding == "utf16le":
1782
+ return "utf-16"
1783
+ if encoding == "utf8-sig":
1784
+ return "utf-8-sig"
1785
+ if encoding == "gb18030":
1786
+ return "gb18030"
1707
1787
  return "utf8"
1708
1788
 
1709
1789
 
1710
1790
  def _write_text_with_encoding(path: Path, content: str, encoding: str) -> None:
1711
- python_encoding = "utf-16" if encoding == "utf16le" else "utf-8"
1712
- path.write_text(content, encoding=python_encoding)
1791
+ path.write_text(content, encoding=_python_text_encoding(encoding))
1713
1792
 
1714
1793
 
1715
1794
  def _coerce_write_content(path: Path, content: object) -> tuple[str, dict[str, object], str | None]:
@@ -1976,25 +2055,41 @@ def _build_shell_command(
1976
2055
  platform_name=platform_name,
1977
2056
  os_name=os_name,
1978
2057
  )
2058
+ process_env = _build_shell_process_env(runtime_environment, env)
1979
2059
  if runtime_environment.command_dialect == "powershell":
1980
2060
  return ShellInvocation(
1981
2061
  shell_path=resolved_shell,
1982
2062
  args=_build_powershell_args(command, marker),
1983
2063
  runtime_environment=runtime_environment,
2064
+ env=process_env,
1984
2065
  )
1985
2066
  if runtime_environment.command_dialect == "cmd":
1986
2067
  return ShellInvocation(
1987
2068
  shell_path=resolved_shell,
1988
2069
  args=_build_cmd_args(command, marker),
1989
2070
  runtime_environment=runtime_environment,
2071
+ env=process_env,
1990
2072
  )
1991
2073
  return ShellInvocation(
1992
2074
  shell_path=resolved_shell,
1993
2075
  args=_build_posix_shell_args(command, marker, resolved_shell),
1994
2076
  runtime_environment=runtime_environment,
2077
+ env=process_env,
1995
2078
  )
1996
2079
 
1997
2080
 
2081
+ def _build_shell_process_env(
2082
+ runtime_environment: RuntimeEnvironment,
2083
+ env: dict[str, str] | None = None,
2084
+ ) -> dict[str, str] | None:
2085
+ if runtime_environment.os_family != "windows":
2086
+ return dict(env) if env is not None else None
2087
+ process_env = dict(os.environ if env is None else env)
2088
+ process_env.setdefault("PYTHONUTF8", "1")
2089
+ process_env.setdefault("PYTHONIOENCODING", "utf-8")
2090
+ return process_env
2091
+
2092
+
1998
2093
  def _build_posix_shell_args(command: str, marker: str, shell_path: str) -> list[str]:
1999
2094
  normalized_command = rewrite_windows_null_redirect(command)
2000
2095
  parts = [
@@ -2015,6 +2110,8 @@ def _build_posix_shell_args(command: str, marker: str, shell_path: str) -> list[
2015
2110
  def _build_powershell_args(command: str, marker: str) -> list[str]:
2016
2111
  script = "\n".join(
2017
2112
  [
2113
+ "$OutputEncoding = [System.Text.UTF8Encoding]::new($false)",
2114
+ "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)",
2018
2115
  "$global:LASTEXITCODE = $null",
2019
2116
  "try {",
2020
2117
  command,
@@ -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":
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
  from pathlib import Path
5
+ import sys
5
6
  from typing import Callable
6
7
  from unicodedata import normalize
7
8
 
@@ -32,6 +33,7 @@ SHIFT_ENTER_SEQUENCES = (
32
33
  "\x1b[27;2;13~", # xterm modified-key format.
33
34
  "\x1b[13;2u", # Kitty/fixterms CSI-u format, used by modern terminals.
34
35
  )
36
+ _WINDOWS_SHIFT_ENTER_PATCH_ATTR = "_deepy_shift_enter_patched"
35
37
 
36
38
 
37
39
  @dataclass(frozen=True)
@@ -102,6 +104,60 @@ def install_shift_enter_key_sequence_overrides() -> None:
102
104
  prefix_cache = getattr(vt100_parser, "_IS_PREFIX_OF_LONGER_MATCH_CACHE", None)
103
105
  if hasattr(prefix_cache, "clear"):
104
106
  prefix_cache.clear()
107
+ install_windows_shift_enter_key_sequence_override()
108
+
109
+
110
+ def install_windows_shift_enter_key_sequence_override(
111
+ *,
112
+ platform_name: str | None = None,
113
+ console_input_reader_cls: type | None = None,
114
+ ) -> bool:
115
+ resolved_platform = platform_name or sys.platform
116
+ if not resolved_platform.startswith("win"):
117
+ return False
118
+ if console_input_reader_cls is None:
119
+ try:
120
+ from prompt_toolkit.input import win32
121
+ except (AssertionError, ImportError):
122
+ return False
123
+ console_input_reader_cls = win32.ConsoleInputReader
124
+ if getattr(console_input_reader_cls, _WINDOWS_SHIFT_ENTER_PATCH_ATTR, False):
125
+ return True
126
+
127
+ from prompt_toolkit.key_binding.key_processor import KeyPress
128
+
129
+ original_handler = console_input_reader_cls._event_to_key_presses
130
+ shift_pressed = getattr(console_input_reader_cls, "SHIFT_PRESSED", 0x0010)
131
+
132
+ def patched_event_to_key_presses(self, ev):
133
+ key_presses = original_handler(self, ev)
134
+ if _is_windows_shift_enter_key_press(ev, key_presses, shift_pressed=shift_pressed):
135
+ return [KeyPress(Keys.Escape, ""), key_presses[0]]
136
+ return key_presses
137
+
138
+ setattr(
139
+ console_input_reader_cls,
140
+ "_deepy_original_event_to_key_presses",
141
+ original_handler,
142
+ )
143
+ console_input_reader_cls._event_to_key_presses = patched_event_to_key_presses
144
+ setattr(console_input_reader_cls, _WINDOWS_SHIFT_ENTER_PATCH_ATTR, True)
145
+ return True
146
+
147
+
148
+ def _is_windows_shift_enter_key_press(
149
+ ev,
150
+ key_presses: list,
151
+ *,
152
+ shift_pressed: int,
153
+ ) -> bool:
154
+ if not key_presses or len(key_presses) != 1:
155
+ return False
156
+ control_key_state = getattr(ev, "ControlKeyState", 0)
157
+ if not control_key_state & shift_pressed:
158
+ return False
159
+ key = getattr(key_presses[0], "key", None)
160
+ return key in {Keys.ControlM, Keys.ControlJ, Keys.Enter}
105
161
 
106
162
 
107
163
  def prompt_for_input(
@@ -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