deepy-cli 0.2.6__tar.gz → 0.2.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 (98) hide show
  1. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/PKG-INFO +18 -1
  2. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/README.md +17 -0
  3. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/pyproject.toml +1 -1
  4. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/__init__.py +1 -1
  5. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/config/__init__.py +4 -0
  6. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/config/settings.py +25 -2
  7. deepy_cli-0.2.8/src/deepy/input_suggestions.py +455 -0
  8. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/sessions/jsonl.py +44 -0
  9. deepy_cli-0.2.8/src/deepy/status.py +319 -0
  10. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/app.py +227 -20
  11. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/commands.py +1 -0
  12. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/runner.py +2 -0
  13. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/screens.py +2 -2
  14. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/widgets.py +48 -3
  15. deepy_cli-0.2.8/src/deepy/ui/exit_summary.py +188 -0
  16. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/prompt_input.py +103 -12
  17. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/slash_commands.py +2 -0
  18. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/terminal.py +138 -6
  19. deepy_cli-0.2.6/src/deepy/status.py +0 -82
  20. deepy_cli-0.2.6/src/deepy/ui/exit_summary.py +0 -143
  21. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/__main__.py +0 -0
  22. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/cli.py +0 -0
  23. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/__init__.py +0 -0
  24. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
  25. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
  26. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/AskUserQuestion.md +0 -0
  27. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/WebFetch.md +0 -0
  28. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/WebSearch.md +0 -0
  29. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/__init__.py +0 -0
  30. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/edit.md +0 -0
  31. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/modify.md +0 -0
  32. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/read.md +0 -0
  33. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/shell.md +0 -0
  34. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/todo_write.md +0 -0
  35. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/write.md +0 -0
  36. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/errors.py +0 -0
  37. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/__init__.py +0 -0
  38. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/agent.py +0 -0
  39. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/compaction.py +0 -0
  40. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/context.py +0 -0
  41. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/events.py +0 -0
  42. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/model_capabilities.py +0 -0
  43. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/provider.py +0 -0
  44. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/replay.py +0 -0
  45. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/runner.py +0 -0
  46. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/thinking.py +0 -0
  47. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/mcp.py +0 -0
  48. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/__init__.py +0 -0
  49. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/compact.py +0 -0
  50. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/init_agents.py +0 -0
  51. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/rules.py +0 -0
  52. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/runtime_context.py +0 -0
  53. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/system.py +0 -0
  54. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/tool_docs.py +0 -0
  55. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/sessions/__init__.py +0 -0
  56. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/sessions/manager.py +0 -0
  57. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/skill_market.py +0 -0
  58. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/skills.py +0 -0
  59. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/todos.py +0 -0
  60. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/__init__.py +0 -0
  61. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/agents.py +0 -0
  62. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/builtin.py +0 -0
  63. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/file_state.py +0 -0
  64. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/result.py +0 -0
  65. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/shell_output.py +0 -0
  66. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/shell_utils.py +0 -0
  67. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/__init__.py +0 -0
  68. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/compat.py +0 -0
  69. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/diff.py +0 -0
  70. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/state.py +0 -0
  71. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/types/__init__.py +0 -0
  72. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/types/sdk.py +0 -0
  73. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/types/tool_payloads.py +0 -0
  74. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/__init__.py +0 -0
  75. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/app.py +0 -0
  76. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/ask_user_question.py +0 -0
  77. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/file_mentions.py +0 -0
  78. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/loading_text.py +0 -0
  79. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/local_command.py +0 -0
  80. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/markdown.py +0 -0
  81. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/message_view.py +0 -0
  82. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/model_picker.py +0 -0
  83. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/prompt_buffer.py +0 -0
  84. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/session_list.py +0 -0
  85. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/session_picker.py +0 -0
  86. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/skill_picker.py +0 -0
  87. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/status_footer.py +0 -0
  88. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/styles.py +0 -0
  89. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/theme_picker.py +0 -0
  90. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/thinking_state.py +0 -0
  91. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/welcome.py +0 -0
  92. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/update_check.py +0 -0
  93. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/usage.py +0 -0
  94. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/utils/__init__.py +0 -0
  95. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/utils/debug_logger.py +0 -0
  96. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/utils/error_logger.py +0 -0
  97. {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/utils/json.py +0 -0
  98. {deepy_cli-0.2.6 → deepy_cli-0.2.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.2.6
3
+ Version: 0.2.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
@@ -168,6 +168,20 @@ scrollable transcript, live thinking and assistant blocks, prompt suggestions
168
168
  for slash commands and `@file` mentions, status/help surfaces, and a Deepy-owned
169
169
  diff view. It is experimental and may change between releases.
170
170
 
171
+ ![Deepy Textual TUI](https://raw.githubusercontent.com/kirineko/deepy/main/asset/deepy-tui.webp)
172
+
173
+ `/status` is available in both the stable terminal UI and the TUI. It shows
174
+ session/project usage, context window pressure, and DeepSeek balance in one
175
+ compact view. The balance API is called only when `/status` is invoked, not on
176
+ startup, model turns, input suggestions, or exit.
177
+
178
+ ![Deepy TUI status panel](https://raw.githubusercontent.com/kirineko/deepy/main/asset/tui-status.webp)
179
+
180
+ `/exit`, `/quit`, and pressing Ctrl+D twice now print the same compact session
181
+ summary in both UIs.
182
+
183
+ ![Deepy TUI session summary](https://raw.githubusercontent.com/kirineko/deepy/main/asset/tui-summary.webp)
184
+
171
185
  Known limitations: the TUI does not add interactive shell/PTTY support yet, and
172
186
  toad / textual-diff-view are only design references. Deepy does not copy their
173
187
  AGPL source or depend on those packages.
@@ -235,6 +249,7 @@ Inside an interactive Deepy session:
235
249
 
236
250
  ```text
237
251
  /model Select model and thinking strength
252
+ /status Show usage, context pressure, and DeepSeek balance
238
253
  /resume Resume a previous project session
239
254
  /new Start a fresh session
240
255
  /compact Compact the active session context
@@ -325,6 +340,7 @@ deepy config theme
325
340
  deepy doctor
326
341
  deepy doctor --live --json
327
342
  deepy status
343
+ deepy tui
328
344
  deepy skills list
329
345
  deepy sessions list
330
346
  deepy sessions show <session-id>
@@ -341,6 +357,7 @@ Inside the interactive terminal:
341
357
  /skill:<name> [request] Invoke a skill directly
342
358
  /init Create or update project AGENTS.md
343
359
  /mcp Show MCP server status and tools
360
+ /status Show usage, context pressure, and DeepSeek balance
344
361
  ```
345
362
 
346
363
  ## AGENTS.md Instructions And Skills
@@ -138,6 +138,20 @@ scrollable transcript, live thinking and assistant blocks, prompt suggestions
138
138
  for slash commands and `@file` mentions, status/help surfaces, and a Deepy-owned
139
139
  diff view. It is experimental and may change between releases.
140
140
 
141
+ ![Deepy Textual TUI](https://raw.githubusercontent.com/kirineko/deepy/main/asset/deepy-tui.webp)
142
+
143
+ `/status` is available in both the stable terminal UI and the TUI. It shows
144
+ session/project usage, context window pressure, and DeepSeek balance in one
145
+ compact view. The balance API is called only when `/status` is invoked, not on
146
+ startup, model turns, input suggestions, or exit.
147
+
148
+ ![Deepy TUI status panel](https://raw.githubusercontent.com/kirineko/deepy/main/asset/tui-status.webp)
149
+
150
+ `/exit`, `/quit`, and pressing Ctrl+D twice now print the same compact session
151
+ summary in both UIs.
152
+
153
+ ![Deepy TUI session summary](https://raw.githubusercontent.com/kirineko/deepy/main/asset/tui-summary.webp)
154
+
141
155
  Known limitations: the TUI does not add interactive shell/PTTY support yet, and
142
156
  toad / textual-diff-view are only design references. Deepy does not copy their
143
157
  AGPL source or depend on those packages.
@@ -205,6 +219,7 @@ Inside an interactive Deepy session:
205
219
 
206
220
  ```text
207
221
  /model Select model and thinking strength
222
+ /status Show usage, context pressure, and DeepSeek balance
208
223
  /resume Resume a previous project session
209
224
  /new Start a fresh session
210
225
  /compact Compact the active session context
@@ -295,6 +310,7 @@ deepy config theme
295
310
  deepy doctor
296
311
  deepy doctor --live --json
297
312
  deepy status
313
+ deepy tui
298
314
  deepy skills list
299
315
  deepy sessions list
300
316
  deepy sessions show <session-id>
@@ -311,6 +327,7 @@ Inside the interactive terminal:
311
327
  /skill:<name> [request] Invoke a skill directly
312
328
  /init Create or update project AGENTS.md
313
329
  /mcp Show MCP server status and tools
330
+ /status Show usage, context pressure, and DeepSeek balance
314
331
  ```
315
332
 
316
333
  ## AGENTS.md Instructions And Skills
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepy-cli"
3
- version = "0.2.6"
3
+ version = "0.2.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.2.6"
3
+ __version__ = "0.2.8"
4
4
 
5
5
 
6
6
  def main() -> None:
@@ -4,6 +4,7 @@ from .settings import (
4
4
  ContextConfig,
5
5
  DEEPSEEK_MODEL_CATALOG,
6
6
  DEFAULT_COMPACT_PRESERVE_RECENT_MESSAGES,
7
+ DEFAULT_INPUT_SUGGESTIONS_ENABLED,
7
8
  DEFAULT_RESERVED_CONTEXT_TOKENS,
8
9
  DEFAULT_UI_THEME,
9
10
  DEFAULT_WEB_SEARCH_SEARXNG_URL,
@@ -26,6 +27,7 @@ from .settings import (
26
27
  mask_secret,
27
28
  settings_to_toml_dict,
28
29
  update_config_model_settings,
30
+ update_config_input_suggestions_enabled,
29
31
  update_config_theme,
30
32
  ui_theme_from_selection,
31
33
  ui_theme_number,
@@ -36,6 +38,7 @@ __all__ = [
36
38
  "ContextConfig",
37
39
  "DEEPSEEK_MODEL_CATALOG",
38
40
  "DEFAULT_COMPACT_PRESERVE_RECENT_MESSAGES",
41
+ "DEFAULT_INPUT_SUGGESTIONS_ENABLED",
39
42
  "DEFAULT_RESERVED_CONTEXT_TOKENS",
40
43
  "DEFAULT_UI_THEME",
41
44
  "DEFAULT_WEB_SEARCH_SEARXNG_URL",
@@ -58,6 +61,7 @@ __all__ = [
58
61
  "mask_secret",
59
62
  "settings_to_toml_dict",
60
63
  "update_config_model_settings",
64
+ "update_config_input_suggestions_enabled",
61
65
  "update_config_theme",
62
66
  "ui_theme_from_selection",
63
67
  "ui_theme_number",
@@ -21,6 +21,7 @@ DEFAULT_MCP_CONNECT_TIMEOUT_SECONDS = 10.0
21
21
  DEFAULT_MCP_CLEANUP_TIMEOUT_SECONDS = 10.0
22
22
  DEFAULT_MCP_CLIENT_SESSION_TIMEOUT_SECONDS = 30.0
23
23
  DEFAULT_MCP_CACHE_TOOLS_LIST = True
24
+ DEFAULT_INPUT_SUGGESTIONS_ENABLED = True
24
25
  REASONING_EFFORTS = {"high", "max"}
25
26
  REASONING_MODES = {"none", "high", "max"}
26
27
  UI_THEMES = {"auto", "dark", "light"}
@@ -284,13 +285,22 @@ class McpConfig:
284
285
  class UiConfig:
285
286
  theme: str = DEFAULT_UI_THEME
286
287
  theme_configured: bool = False
288
+ input_suggestions_enabled: bool = DEFAULT_INPUT_SUGGESTIONS_ENABLED
287
289
 
288
290
  @classmethod
289
291
  def from_mapping(cls, raw: Mapping[str, Any]) -> Self:
290
292
  theme = raw.get("theme")
293
+ input_suggestions_enabled = _as_bool(
294
+ raw.get("input_suggestions_enabled"),
295
+ DEFAULT_INPUT_SUGGESTIONS_ENABLED,
296
+ )
291
297
  if isinstance(theme, str) and theme.strip() in UI_THEMES:
292
- return cls(theme=theme.strip(), theme_configured=True)
293
- return cls()
298
+ return cls(
299
+ theme=theme.strip(),
300
+ theme_configured=True,
301
+ input_suggestions_enabled=input_suggestions_enabled,
302
+ )
303
+ return cls(input_suggestions_enabled=input_suggestions_enabled)
294
304
 
295
305
 
296
306
  @dataclass(frozen=True)
@@ -441,6 +451,7 @@ def write_config(
441
451
  },
442
452
  "ui": {
443
453
  "theme": theme,
454
+ "input_suggestions_enabled": DEFAULT_INPUT_SUGGESTIONS_ENABLED,
444
455
  },
445
456
  }
446
457
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -492,6 +503,18 @@ def update_config_theme(config_path: Path, theme: str) -> None:
492
503
  _write_private_toml(path, raw)
493
504
 
494
505
 
506
+ def update_config_input_suggestions_enabled(config_path: Path, enabled: bool) -> None:
507
+ path = config_path.expanduser()
508
+ if path.suffix == ".json":
509
+ raise ValueError("Deepy only supports TOML config files; JSON config is not supported.")
510
+ raw = _read_toml_mapping(path)
511
+ ui = raw.get("ui")
512
+ ui_map = dict(ui) if isinstance(ui, Mapping) else {}
513
+ ui_map["input_suggestions_enabled"] = bool(enabled)
514
+ raw["ui"] = ui_map
515
+ _write_private_toml(path, raw)
516
+
517
+
495
518
  def _read_toml_mapping(path: Path) -> dict[str, Any]:
496
519
  if not path.exists():
497
520
  return {}
@@ -0,0 +1,455 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from dataclasses import dataclass, field, replace
6
+ from typing import Any, Literal, Mapping, Sequence, cast
7
+
8
+ from agents import ModelSettings
9
+ from openai import AsyncOpenAI
10
+ from openai.types.chat import ChatCompletionMessageParam
11
+
12
+ from deepy.config import Settings
13
+ from deepy.llm.thinking import build_thinking_extra_body
14
+ from deepy.usage import TokenUsage, normalize_usage
15
+ from deepy.utils import log_debug_event
16
+ from deepy.utils import json as json_utils
17
+
18
+ INPUT_SUGGESTION_MODEL = "deepseek-v4-flash"
19
+ INPUT_SUGGESTION_DELAY_SECONDS = 0.3
20
+ MIN_ASSISTANT_REPLIES = 2
21
+ MAX_RECENT_HISTORY_ITEMS = 40
22
+
23
+ SUGGESTION_PROMPT = """[SUGGESTION MODE: Suggest what the user might naturally type next.]
24
+
25
+ FIRST: Read the LAST FEW LINES of the assistant's most recent message. Next-step
26
+ hints, tips, and actionable suggestions usually appear there. Then check the
27
+ user's recent messages and original request.
28
+
29
+ Predict what the user would type next, not what the assistant should do.
30
+
31
+ If the assistant's last message contains a hint like "Tip: type X" or
32
+ "type X to ...", extract X as the suggestion when it is natural.
33
+
34
+ Stay silent if the next step is not obvious from the conversation.
35
+
36
+ Format: 2-12 words, match the user's style. Or return an empty string.
37
+ Reply with ONLY the suggestion, no quotes or explanation."""
38
+
39
+ ALLOWED_SINGLE_WORDS = frozenset(
40
+ {
41
+ "yes",
42
+ "yeah",
43
+ "yep",
44
+ "yea",
45
+ "yup",
46
+ "sure",
47
+ "ok",
48
+ "okay",
49
+ "push",
50
+ "commit",
51
+ "deploy",
52
+ "stop",
53
+ "continue",
54
+ "check",
55
+ "exit",
56
+ "quit",
57
+ "no",
58
+ }
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class InputSuggestion:
64
+ text: str
65
+ usage: TokenUsage = field(default_factory=TokenUsage)
66
+ model: str = INPUT_SUGGESTION_MODEL
67
+ elapsed_ms: int = 0
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class InputSuggestionState:
72
+ text: str | None = None
73
+ visible: bool = False
74
+ shown_at: float = 0.0
75
+
76
+
77
+ @dataclass
78
+ class InputSuggestionController:
79
+ enabled: bool = True
80
+ state: InputSuggestionState = field(default_factory=InputSuggestionState)
81
+ last_accepted_method: Literal["tab", "right"] | None = None
82
+ _version: int = 0
83
+
84
+ def set_suggestion(self, text: str | None, *, visible: bool = True) -> None:
85
+ if not self.enabled or not text:
86
+ self.clear()
87
+ return
88
+ self._version += 1
89
+ self.last_accepted_method = None
90
+ self.state = InputSuggestionState(
91
+ text=text,
92
+ visible=visible,
93
+ shown_at=time.time() if visible else 0.0,
94
+ )
95
+
96
+ async def set_suggestion_after_delay(self, text: str | None) -> None:
97
+ if not text:
98
+ self.clear()
99
+ return
100
+ self._version += 1
101
+ version = self._version
102
+ await asyncio.sleep(INPUT_SUGGESTION_DELAY_SECONDS)
103
+ if version != self._version:
104
+ return
105
+ self.set_suggestion(text)
106
+
107
+ def accept(self, method: Literal["tab", "right"] = "tab") -> str | None:
108
+ if not self.state.text:
109
+ return None
110
+ text = self.state.text
111
+ self._version += 1
112
+ self.last_accepted_method = method
113
+ self.state = InputSuggestionState(text=text, visible=False)
114
+ return text
115
+
116
+ def dismiss(self) -> None:
117
+ self.clear()
118
+
119
+ def hide(self) -> None:
120
+ if self.state.text:
121
+ self.state = InputSuggestionState(text=self.state.text, visible=False)
122
+
123
+ def reveal(self) -> None:
124
+ if self.enabled and self.state.text and not self.state.visible:
125
+ self.state = InputSuggestionState(
126
+ text=self.state.text,
127
+ visible=True,
128
+ shown_at=time.time(),
129
+ )
130
+
131
+ def clear(self) -> None:
132
+ self._version += 1
133
+ self.last_accepted_method = None
134
+ self.state = InputSuggestionState()
135
+
136
+ def set_enabled(self, enabled: bool) -> None:
137
+ self.enabled = enabled
138
+ if not enabled:
139
+ self.clear()
140
+
141
+
142
+ def input_suggestion_model_settings() -> ModelSettings:
143
+ return ModelSettings(
144
+ include_usage=True,
145
+ store=False,
146
+ extra_body=build_thinking_extra_body(False),
147
+ )
148
+
149
+
150
+ def assistant_reply_count(items: Sequence[Mapping[str, Any]]) -> int:
151
+ return sum(1 for item in items if _item_role(item) in {"assistant", "model"})
152
+
153
+
154
+ def is_eligible_for_input_suggestion(
155
+ items: Sequence[Mapping[str, Any]],
156
+ *,
157
+ enabled: bool,
158
+ interactive: bool = True,
159
+ idle: bool = True,
160
+ has_pending_questions: bool = False,
161
+ turn_status: str = "completed",
162
+ ) -> bool:
163
+ return (
164
+ enabled
165
+ and interactive
166
+ and idle
167
+ and not has_pending_questions
168
+ and turn_status == "completed"
169
+ and assistant_reply_count(items) >= MIN_ASSISTANT_REPLIES
170
+ )
171
+
172
+
173
+ def recent_suggestion_messages(items: Sequence[Mapping[str, Any]]) -> list[dict[str, str]]:
174
+ recent = list(items)[-MAX_RECENT_HISTORY_ITEMS:]
175
+ messages: list[dict[str, str]] = []
176
+ for item in recent:
177
+ role = _item_role(item)
178
+ if role not in {"user", "assistant", "model"}:
179
+ continue
180
+ content = _item_text(item).strip()
181
+ if not content:
182
+ continue
183
+ messages.append(
184
+ {
185
+ "role": "assistant" if role == "model" else role,
186
+ "content": content,
187
+ }
188
+ )
189
+ return messages
190
+
191
+
192
+ async def generate_input_suggestion(
193
+ settings: Settings,
194
+ items: Sequence[Mapping[str, Any]],
195
+ *,
196
+ timeout_seconds: float = 10.0,
197
+ ) -> InputSuggestion | None:
198
+ if not settings.model.api_key:
199
+ _log_input_suggestion_debug(settings, {"status": "skipped", "reason": "missing_api_key"})
200
+ return None
201
+ messages = recent_suggestion_messages(items)
202
+ if not messages:
203
+ _log_input_suggestion_debug(settings, {"status": "skipped", "reason": "empty_context"})
204
+ return None
205
+ request_messages = cast(
206
+ list[ChatCompletionMessageParam],
207
+ [
208
+ *messages,
209
+ {"role": "user", "content": SUGGESTION_PROMPT},
210
+ ],
211
+ )
212
+ client = AsyncOpenAI(base_url=settings.model.base_url, api_key=settings.model.api_key)
213
+ settings_payload = input_suggestion_model_settings()
214
+ started_at = time.time()
215
+ try:
216
+ response = await asyncio.wait_for(
217
+ client.chat.completions.create(
218
+ model=INPUT_SUGGESTION_MODEL,
219
+ messages=request_messages,
220
+ temperature=0,
221
+ max_tokens=64,
222
+ extra_body=settings_payload.extra_body,
223
+ store=settings_payload.store,
224
+ ),
225
+ timeout=timeout_seconds,
226
+ )
227
+ except Exception as exc:
228
+ _log_input_suggestion_debug(
229
+ settings,
230
+ {"status": "failed", "reason": "api_error", "error": exc},
231
+ )
232
+ return None
233
+ text = ""
234
+ choices = getattr(response, "choices", None) or []
235
+ if choices:
236
+ message = getattr(choices[0], "message", None)
237
+ content = getattr(message, "content", None)
238
+ text = content if isinstance(content, str) else ""
239
+ suggestion = parse_suggestion_text(text)
240
+ if not suggestion:
241
+ _log_input_suggestion_debug(settings, {"status": "skipped", "reason": "empty_response"})
242
+ return None
243
+ filter_reason = get_filter_reason(suggestion)
244
+ if filter_reason:
245
+ _log_input_suggestion_debug(
246
+ settings,
247
+ {
248
+ "status": "filtered",
249
+ "reason": filter_reason,
250
+ "suggestion": suggestion,
251
+ },
252
+ )
253
+ return None
254
+ usage = normalize_usage(getattr(response, "usage", None))
255
+ _log_input_suggestion_debug(
256
+ settings,
257
+ {
258
+ "status": "generated",
259
+ "model": INPUT_SUGGESTION_MODEL,
260
+ "suggestion": suggestion,
261
+ "usage": usage.to_dict(),
262
+ },
263
+ )
264
+ return InputSuggestion(
265
+ text=suggestion,
266
+ usage=usage,
267
+ elapsed_ms=int((time.time() - started_at) * 1000),
268
+ )
269
+
270
+
271
+ def parse_suggestion_text(text: str) -> str:
272
+ stripped = text.strip().strip('"').strip("'").strip()
273
+ if not stripped:
274
+ return ""
275
+ if stripped.startswith("{"):
276
+ try:
277
+ parsed = json_utils.loads(stripped)
278
+ except Exception:
279
+ return stripped
280
+ if isinstance(parsed, dict):
281
+ raw = parsed.get("suggestion")
282
+ return raw.strip() if isinstance(raw, str) else ""
283
+ return stripped
284
+
285
+
286
+ def get_filter_reason(suggestion: str) -> str | None:
287
+ lower = suggestion.lower().strip()
288
+ word_count = len(suggestion.strip().split())
289
+
290
+ if lower == "done":
291
+ return "done"
292
+ if (
293
+ lower in {"nothing found", "nothing found."}
294
+ or lower.startswith("nothing to suggest")
295
+ or lower.startswith("no suggestion")
296
+ or "silence is" in lower
297
+ or "stay silent" in lower
298
+ or lower == "silence"
299
+ ):
300
+ return "meta_text"
301
+ if lower.startswith(
302
+ (
303
+ "api error:",
304
+ "prompt is too long",
305
+ "request timed out",
306
+ "invalid api key",
307
+ "image was too large",
308
+ )
309
+ ):
310
+ return "error_message"
311
+ if suggestion.startswith(("(", "[")) and suggestion.endswith((")", "]")):
312
+ return "meta_wrapped"
313
+ if _has_prefixed_label(suggestion):
314
+ return "prefixed_label"
315
+ if "\n" in suggestion or "*" in suggestion or "**" in suggestion:
316
+ return "has_formatting"
317
+ if len(suggestion) >= 100:
318
+ return "too_long"
319
+ if suggestion.endswith("?") or "?" in suggestion:
320
+ return "question"
321
+ if _has_cjk(suggestion):
322
+ if len(suggestion) < 2:
323
+ return "too_few_words"
324
+ if len(suggestion) > 30:
325
+ return "too_many_words"
326
+ else:
327
+ if word_count < 2 and not suggestion.startswith("/") and lower not in ALLOWED_SINGLE_WORDS:
328
+ return "too_few_words"
329
+ if word_count > 12:
330
+ return "too_many_words"
331
+ if _has_multiple_sentences(suggestion):
332
+ return "multiple_sentences"
333
+ if _is_evaluative(lower):
334
+ return "evaluative"
335
+ if _is_ai_voice(suggestion):
336
+ return "ai_voice"
337
+ return None
338
+
339
+
340
+ def with_recorded_input_suggestion_usage(
341
+ suggestion: InputSuggestion | None,
342
+ *,
343
+ usage: TokenUsage | None = None,
344
+ ) -> InputSuggestion | None:
345
+ if suggestion is None or usage is None:
346
+ return suggestion
347
+ return replace(suggestion, usage=usage)
348
+
349
+
350
+ def _item_role(item: Mapping[str, Any]) -> str:
351
+ role = item.get("role")
352
+ if isinstance(role, str):
353
+ return role
354
+ item_type = item.get("type")
355
+ if item_type == "message":
356
+ raw_role = item.get("role")
357
+ return raw_role if isinstance(raw_role, str) else ""
358
+ return item_type if isinstance(item_type, str) else ""
359
+
360
+
361
+ def _item_text(item: Mapping[str, Any]) -> str:
362
+ content = item.get("content")
363
+ if isinstance(content, str):
364
+ return content
365
+ if isinstance(content, list):
366
+ parts: list[str] = []
367
+ for part in content:
368
+ if isinstance(part, str):
369
+ parts.append(part)
370
+ elif isinstance(part, Mapping):
371
+ text = part.get("text")
372
+ if isinstance(text, str):
373
+ parts.append(text)
374
+ return "\n".join(parts)
375
+ output = item.get("output")
376
+ return output if isinstance(output, str) else ""
377
+
378
+
379
+ def _has_cjk(value: str) -> bool:
380
+ return any(
381
+ "\u4e00" <= char <= "\u9fff"
382
+ or "\u3040" <= char <= "\u30ff"
383
+ or "\uac00" <= char <= "\ud7af"
384
+ for char in value
385
+ )
386
+
387
+
388
+ def _has_prefixed_label(value: str) -> bool:
389
+ prefix, sep, rest = value.partition(":")
390
+ return bool(sep and prefix.replace("_", "").isalnum() and rest.startswith(" "))
391
+
392
+
393
+ def _has_multiple_sentences(value: str) -> bool:
394
+ for index, char in enumerate(value[:-2]):
395
+ if char in ".!?" and value[index + 1] == " " and value[index + 2].isupper():
396
+ return True
397
+ return False
398
+
399
+
400
+ def _is_evaluative(lower: str) -> bool:
401
+ phrases = (
402
+ "thanks",
403
+ "thank you",
404
+ "looks good",
405
+ "sounds good",
406
+ "that works",
407
+ "that worked",
408
+ "that's all",
409
+ "nice",
410
+ "great",
411
+ "perfect",
412
+ "makes sense",
413
+ "awesome",
414
+ "excellent",
415
+ )
416
+ return any(phrase in lower for phrase in phrases)
417
+
418
+
419
+ def _is_ai_voice(value: str) -> bool:
420
+ lower = value.lower()
421
+ prefixes = (
422
+ "let me",
423
+ "i'll",
424
+ "i've",
425
+ "i'm",
426
+ "i can",
427
+ "i would",
428
+ "i think",
429
+ "i notice",
430
+ "here's",
431
+ "here is",
432
+ "here are",
433
+ "that's",
434
+ "this is",
435
+ "this will",
436
+ "you can",
437
+ "you should",
438
+ "you could",
439
+ "sure,",
440
+ "of course",
441
+ "certainly",
442
+ )
443
+ return lower.startswith(prefixes)
444
+
445
+
446
+ def _log_input_suggestion_debug(settings: Settings, payload: dict[str, Any]) -> None:
447
+ if not settings.logging.debug:
448
+ return
449
+ log_debug_event(
450
+ {
451
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
452
+ "location": "deepy.input_suggestions.generate_input_suggestion",
453
+ **payload,
454
+ }
455
+ )