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.
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/PKG-INFO +18 -1
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/README.md +17 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/pyproject.toml +1 -1
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/__init__.py +1 -1
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/config/__init__.py +4 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/config/settings.py +25 -2
- deepy_cli-0.2.8/src/deepy/input_suggestions.py +455 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/sessions/jsonl.py +44 -0
- deepy_cli-0.2.8/src/deepy/status.py +319 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/app.py +227 -20
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/commands.py +1 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/runner.py +2 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/screens.py +2 -2
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/widgets.py +48 -3
- deepy_cli-0.2.8/src/deepy/ui/exit_summary.py +188 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/prompt_input.py +103 -12
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/slash_commands.py +2 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/terminal.py +138 -6
- deepy_cli-0.2.6/src/deepy/status.py +0 -82
- deepy_cli-0.2.6/src/deepy/ui/exit_summary.py +0 -143
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/__main__.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/cli.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/__init__.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/AskUserQuestion.md +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/WebFetch.md +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/WebSearch.md +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/__init__.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/edit.md +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/modify.md +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/read.md +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/shell.md +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/todo_write.md +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/data/tools/write.md +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/errors.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/__init__.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/agent.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/compaction.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/context.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/events.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/model_capabilities.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/provider.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/replay.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/runner.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/llm/thinking.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/mcp.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/__init__.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/compact.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/init_agents.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/rules.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/runtime_context.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/system.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/prompts/tool_docs.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/sessions/__init__.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/sessions/manager.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/skill_market.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/skills.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/todos.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/__init__.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/agents.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/builtin.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/file_state.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/result.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/shell_output.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tools/shell_utils.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/__init__.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/compat.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/diff.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/tui/state.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/types/__init__.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/types/sdk.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/types/tool_payloads.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/__init__.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/app.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/ask_user_question.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/file_mentions.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/loading_text.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/local_command.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/markdown.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/message_view.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/model_picker.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/prompt_buffer.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/session_list.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/session_picker.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/skill_picker.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/status_footer.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/styles.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/theme_picker.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/thinking_state.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/ui/welcome.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/update_check.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/usage.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/utils/__init__.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/utils/debug_logger.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/utils/error_logger.py +0 -0
- {deepy_cli-0.2.6 → deepy_cli-0.2.8}/src/deepy/utils/json.py +0 -0
- {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.
|
|
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
|
+

|
|
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
|
+

|
|
179
|
+
|
|
180
|
+
`/exit`, `/quit`, and pressing Ctrl+D twice now print the same compact session
|
|
181
|
+
summary in both UIs.
|
|
182
|
+
|
|
183
|
+

|
|
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
|
+

|
|
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
|
+

|
|
149
|
+
|
|
150
|
+
`/exit`, `/quit`, and pressing Ctrl+D twice now print the same compact session
|
|
151
|
+
summary in both UIs.
|
|
152
|
+
|
|
153
|
+

|
|
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
|
|
@@ -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(
|
|
293
|
-
|
|
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
|
+
)
|