deepy-cli 0.1.1__py3-none-any.whl
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/__init__.py +9 -0
- deepy/__main__.py +7 -0
- deepy/cli.py +413 -0
- deepy/config/__init__.py +21 -0
- deepy/config/settings.py +237 -0
- deepy/data/__init__.py +1 -0
- deepy/data/tools/AskUserQuestion.md +10 -0
- deepy/data/tools/WebFetch.md +9 -0
- deepy/data/tools/WebSearch.md +9 -0
- deepy/data/tools/__init__.py +1 -0
- deepy/data/tools/bash.md +7 -0
- deepy/data/tools/edit.md +13 -0
- deepy/data/tools/modify.md +17 -0
- deepy/data/tools/read.md +8 -0
- deepy/data/tools/write.md +12 -0
- deepy/errors.py +63 -0
- deepy/llm/__init__.py +13 -0
- deepy/llm/agent.py +31 -0
- deepy/llm/context.py +109 -0
- deepy/llm/events.py +187 -0
- deepy/llm/model_capabilities.py +7 -0
- deepy/llm/provider.py +81 -0
- deepy/llm/replay.py +120 -0
- deepy/llm/runner.py +412 -0
- deepy/llm/thinking.py +30 -0
- deepy/prompts/__init__.py +6 -0
- deepy/prompts/compact.py +100 -0
- deepy/prompts/rules.py +24 -0
- deepy/prompts/runtime_context.py +98 -0
- deepy/prompts/system.py +72 -0
- deepy/prompts/tool_docs.py +21 -0
- deepy/sessions/__init__.py +17 -0
- deepy/sessions/jsonl.py +306 -0
- deepy/sessions/manager.py +202 -0
- deepy/skills.py +202 -0
- deepy/status.py +65 -0
- deepy/tools/__init__.py +6 -0
- deepy/tools/agents.py +343 -0
- deepy/tools/builtin.py +2113 -0
- deepy/tools/file_state.py +85 -0
- deepy/tools/result.py +54 -0
- deepy/tools/shell_utils.py +83 -0
- deepy/ui/__init__.py +5 -0
- deepy/ui/app.py +118 -0
- deepy/ui/ask_user_question.py +182 -0
- deepy/ui/exit_summary.py +142 -0
- deepy/ui/loading_text.py +87 -0
- deepy/ui/markdown.py +152 -0
- deepy/ui/message_view.py +546 -0
- deepy/ui/prompt_buffer.py +176 -0
- deepy/ui/prompt_input.py +286 -0
- deepy/ui/session_list.py +140 -0
- deepy/ui/session_picker.py +179 -0
- deepy/ui/slash_commands.py +67 -0
- deepy/ui/styles.py +21 -0
- deepy/ui/terminal.py +959 -0
- deepy/ui/thinking_state.py +29 -0
- deepy/ui/welcome.py +195 -0
- deepy/update_check.py +195 -0
- deepy/usage.py +192 -0
- deepy/utils/__init__.py +15 -0
- deepy/utils/debug_logger.py +62 -0
- deepy/utils/error_logger.py +107 -0
- deepy/utils/json.py +29 -0
- deepy/utils/notify.py +66 -0
- deepy_cli-0.1.1.dist-info/METADATA +205 -0
- deepy_cli-0.1.1.dist-info/RECORD +69 -0
- deepy_cli-0.1.1.dist-info/WHEEL +4 -0
- deepy_cli-0.1.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class PromptBufferState:
|
|
9
|
+
text: str = ""
|
|
10
|
+
cursor: int = 0
|
|
11
|
+
|
|
12
|
+
def normalized(self) -> "PromptBufferState":
|
|
13
|
+
return PromptBufferState(self.text, _clamp(self.cursor, 0, len(self.text)))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
EMPTY_BUFFER = PromptBufferState()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def insert_text(state: PromptBufferState, value: str) -> PromptBufferState:
|
|
20
|
+
state = state.normalized()
|
|
21
|
+
if not value:
|
|
22
|
+
return state
|
|
23
|
+
text = state.text[: state.cursor] + value + state.text[state.cursor :]
|
|
24
|
+
return PromptBufferState(text, state.cursor + len(value))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def backspace(state: PromptBufferState) -> PromptBufferState:
|
|
28
|
+
state = state.normalized()
|
|
29
|
+
if state.cursor == 0:
|
|
30
|
+
return state
|
|
31
|
+
text = state.text[: state.cursor - 1] + state.text[state.cursor :]
|
|
32
|
+
return PromptBufferState(text, state.cursor - 1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def delete_forward(state: PromptBufferState) -> PromptBufferState:
|
|
36
|
+
state = state.normalized()
|
|
37
|
+
if state.cursor >= len(state.text):
|
|
38
|
+
return state
|
|
39
|
+
text = state.text[: state.cursor] + state.text[state.cursor + 1 :]
|
|
40
|
+
return PromptBufferState(text, state.cursor)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def move_left(state: PromptBufferState) -> PromptBufferState:
|
|
44
|
+
state = state.normalized()
|
|
45
|
+
return PromptBufferState(state.text, max(state.cursor - 1, 0))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def move_right(state: PromptBufferState) -> PromptBufferState:
|
|
49
|
+
state = state.normalized()
|
|
50
|
+
return PromptBufferState(state.text, min(state.cursor + 1, len(state.text)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def move_word_left(state: PromptBufferState) -> PromptBufferState:
|
|
54
|
+
state = state.normalized()
|
|
55
|
+
cursor = state.cursor
|
|
56
|
+
while cursor > 0 and state.text[cursor - 1].isspace():
|
|
57
|
+
cursor -= 1
|
|
58
|
+
while cursor > 0 and not state.text[cursor - 1].isspace():
|
|
59
|
+
cursor -= 1
|
|
60
|
+
return PromptBufferState(state.text, cursor)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def move_word_right(state: PromptBufferState) -> PromptBufferState:
|
|
64
|
+
state = state.normalized()
|
|
65
|
+
cursor = state.cursor
|
|
66
|
+
while cursor < len(state.text) and state.text[cursor].isspace():
|
|
67
|
+
cursor += 1
|
|
68
|
+
while cursor < len(state.text) and not state.text[cursor].isspace():
|
|
69
|
+
cursor += 1
|
|
70
|
+
return PromptBufferState(state.text, cursor)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def move_up(state: PromptBufferState) -> PromptBufferState:
|
|
74
|
+
state = state.normalized()
|
|
75
|
+
location = _locate(state)
|
|
76
|
+
if location.line == 0:
|
|
77
|
+
return PromptBufferState(state.text, 0)
|
|
78
|
+
previous_line_end = location.line_start - 1
|
|
79
|
+
previous_line_start = state.text.rfind("\n", 0, previous_line_end) + 1
|
|
80
|
+
previous_line_length = previous_line_end - previous_line_start
|
|
81
|
+
target_column = min(location.column, previous_line_length)
|
|
82
|
+
return PromptBufferState(state.text, previous_line_start + target_column)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def move_down(state: PromptBufferState) -> PromptBufferState:
|
|
86
|
+
state = state.normalized()
|
|
87
|
+
location = _locate(state)
|
|
88
|
+
if location.line_end >= len(state.text):
|
|
89
|
+
return PromptBufferState(state.text, len(state.text))
|
|
90
|
+
next_line_start = location.line_end + 1
|
|
91
|
+
next_line_newline = state.text.find("\n", next_line_start)
|
|
92
|
+
next_line_end = len(state.text) if next_line_newline == -1 else next_line_newline
|
|
93
|
+
next_line_length = next_line_end - next_line_start
|
|
94
|
+
target_column = min(location.column, next_line_length)
|
|
95
|
+
return PromptBufferState(state.text, next_line_start + target_column)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def move_line_start(state: PromptBufferState) -> PromptBufferState:
|
|
99
|
+
state = state.normalized()
|
|
100
|
+
return PromptBufferState(state.text, _locate(state).line_start)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def move_line_end(state: PromptBufferState) -> PromptBufferState:
|
|
104
|
+
state = state.normalized()
|
|
105
|
+
return PromptBufferState(state.text, _locate(state).line_end)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def kill_line(state: PromptBufferState) -> PromptBufferState:
|
|
109
|
+
state = state.normalized()
|
|
110
|
+
line_end = _locate(state).line_end
|
|
111
|
+
if state.cursor >= line_end:
|
|
112
|
+
return state
|
|
113
|
+
text = state.text[: state.cursor] + state.text[line_end:]
|
|
114
|
+
return PromptBufferState(text, state.cursor)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def delete_word_before(state: PromptBufferState) -> PromptBufferState:
|
|
118
|
+
state = state.normalized()
|
|
119
|
+
end = state.cursor
|
|
120
|
+
start = end
|
|
121
|
+
while start > 0 and state.text[start - 1].isspace():
|
|
122
|
+
start -= 1
|
|
123
|
+
while start > 0 and not state.text[start - 1].isspace():
|
|
124
|
+
start -= 1
|
|
125
|
+
if start == end:
|
|
126
|
+
return state
|
|
127
|
+
return PromptBufferState(state.text[:start] + state.text[end:], start)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def reset() -> PromptBufferState:
|
|
131
|
+
return EMPTY_BUFFER
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_empty(state: PromptBufferState) -> bool:
|
|
135
|
+
return len(state.text) == 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_current_slash_token(state: PromptBufferState) -> str | None:
|
|
139
|
+
state = state.normalized()
|
|
140
|
+
if not state.text:
|
|
141
|
+
return None
|
|
142
|
+
before_cursor = state.text[: state.cursor]
|
|
143
|
+
line_start = before_cursor.rfind("\n") + 1
|
|
144
|
+
line = before_cursor[line_start:]
|
|
145
|
+
if not line.startswith("/"):
|
|
146
|
+
return None
|
|
147
|
+
if re.search(r"\s", line):
|
|
148
|
+
return None
|
|
149
|
+
return line
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class _Location:
|
|
154
|
+
line: int
|
|
155
|
+
column: int
|
|
156
|
+
line_start: int
|
|
157
|
+
line_end: int
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _locate(state: PromptBufferState) -> _Location:
|
|
161
|
+
before = state.text[: state.cursor]
|
|
162
|
+
line_start = before.rfind("\n") + 1
|
|
163
|
+
line = before.count("\n")
|
|
164
|
+
after = state.text[state.cursor :]
|
|
165
|
+
next_newline = after.find("\n")
|
|
166
|
+
line_end = len(state.text) if next_newline == -1 else state.cursor + next_newline
|
|
167
|
+
return _Location(
|
|
168
|
+
line=line,
|
|
169
|
+
column=state.cursor - line_start,
|
|
170
|
+
line_start=line_start,
|
|
171
|
+
line_end=line_end,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _clamp(value: int, minimum: int, maximum: int) -> int:
|
|
176
|
+
return max(minimum, min(value, maximum))
|
deepy/ui/prompt_input.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable
|
|
6
|
+
from unicodedata import normalize
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit import PromptSession
|
|
9
|
+
from prompt_toolkit.completion import WordCompleter
|
|
10
|
+
from prompt_toolkit.formatted_text import AnyFormattedText
|
|
11
|
+
from prompt_toolkit.history import FileHistory
|
|
12
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
13
|
+
from prompt_toolkit.keys import Keys
|
|
14
|
+
from prompt_toolkit.styles import Style
|
|
15
|
+
|
|
16
|
+
from deepy.skills import SkillInfo
|
|
17
|
+
from deepy.ui.prompt_buffer import PromptBufferState
|
|
18
|
+
from deepy.ui.slash_commands import SlashCommandItem
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEFAULT_PROMPT_HISTORY = Path.home() / ".deepy" / "prompt-history.txt"
|
|
22
|
+
CTRL_D_EXIT_CONFIRM_SIGNAL = "\0deepy:ctrl-d-exit-confirm\0"
|
|
23
|
+
PROMPT_TOOLBAR_BACKGROUND = "#24283b"
|
|
24
|
+
PROMPT_TOOLBAR_FOREGROUND = "#d7def8"
|
|
25
|
+
PROMPT_TOOLBAR_HELP = "Enter send · Shift+Enter newline · / commands · Esc interrupt · Ctrl+D twice exit"
|
|
26
|
+
PROMPT_MESSAGE: AnyFormattedText = [("class:prompt", "> ")]
|
|
27
|
+
PROMPT_PLACEHOLDER: AnyFormattedText = [("class:placeholder", "Type your message...")]
|
|
28
|
+
PROMPT_TOOLBAR: AnyFormattedText = [("class:toolbar.help", PROMPT_TOOLBAR_HELP)]
|
|
29
|
+
PROMPT_STYLE = Style.from_dict(
|
|
30
|
+
{
|
|
31
|
+
"prompt": "ansicyan bold",
|
|
32
|
+
"placeholder": "#8a90aa",
|
|
33
|
+
"toolbar": f"bg:{PROMPT_TOOLBAR_BACKGROUND} {PROMPT_TOOLBAR_FOREGROUND}",
|
|
34
|
+
"toolbar.context": f"bg:{PROMPT_TOOLBAR_BACKGROUND} #8bd5ca bold",
|
|
35
|
+
"toolbar.separator": f"bg:{PROMPT_TOOLBAR_BACKGROUND} #6c7086",
|
|
36
|
+
"toolbar.help": f"bg:{PROMPT_TOOLBAR_BACKGROUND} {PROMPT_TOOLBAR_FOREGROUND}",
|
|
37
|
+
"bottom-toolbar": f"bg:{PROMPT_TOOLBAR_BACKGROUND} {PROMPT_TOOLBAR_FOREGROUND}",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
SHIFT_ENTER_SEQUENCES = (
|
|
41
|
+
"\x1b[27;2;13~", # xterm modified-key format.
|
|
42
|
+
"\x1b[13;2u", # Kitty/fixterms CSI-u format, used by modern terminals.
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class PromptCursorPlacement:
|
|
48
|
+
rows_up: int
|
|
49
|
+
column: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_prompt_session(
|
|
53
|
+
*,
|
|
54
|
+
slash_commands: list[SlashCommandItem] | None = None,
|
|
55
|
+
history_path: Path | None = None,
|
|
56
|
+
on_interrupt: Callable[[], None] | None = None,
|
|
57
|
+
) -> PromptSession[str]:
|
|
58
|
+
install_shift_enter_key_sequence_overrides()
|
|
59
|
+
path = history_path or DEFAULT_PROMPT_HISTORY
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
path.touch(exist_ok=True)
|
|
62
|
+
labels = [item.label for item in slash_commands or []]
|
|
63
|
+
return PromptSession(
|
|
64
|
+
history=FileHistory(str(path)),
|
|
65
|
+
completer=WordCompleter(labels, ignore_case=True, sentence=True),
|
|
66
|
+
complete_while_typing=True,
|
|
67
|
+
multiline=True,
|
|
68
|
+
key_bindings=build_prompt_key_bindings(on_interrupt=on_interrupt),
|
|
69
|
+
style=PROMPT_STYLE,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_prompt_key_bindings(
|
|
74
|
+
*,
|
|
75
|
+
on_interrupt: Callable[[], None] | None = None,
|
|
76
|
+
) -> KeyBindings:
|
|
77
|
+
install_shift_enter_key_sequence_overrides()
|
|
78
|
+
bindings = KeyBindings()
|
|
79
|
+
|
|
80
|
+
@bindings.add("escape")
|
|
81
|
+
def _(event) -> None: # pragma: no cover - prompt_toolkit calls this callback
|
|
82
|
+
if on_interrupt is not None:
|
|
83
|
+
on_interrupt()
|
|
84
|
+
|
|
85
|
+
@bindings.add("enter")
|
|
86
|
+
def _(event) -> None: # pragma: no cover - prompt_toolkit calls this callback
|
|
87
|
+
event.current_buffer.validate_and_handle()
|
|
88
|
+
|
|
89
|
+
@bindings.add("c-d")
|
|
90
|
+
def _(event) -> None: # pragma: no cover - prompt_toolkit calls this callback
|
|
91
|
+
if event.current_buffer.text:
|
|
92
|
+
event.current_buffer.delete()
|
|
93
|
+
return
|
|
94
|
+
event.app.exit(result=CTRL_D_EXIT_CONFIRM_SIGNAL)
|
|
95
|
+
|
|
96
|
+
@bindings.add("escape", "enter")
|
|
97
|
+
@bindings.add("escape", "c-j")
|
|
98
|
+
def _(event) -> None: # pragma: no cover - prompt_toolkit calls this callback
|
|
99
|
+
event.current_buffer.insert_text("\n")
|
|
100
|
+
|
|
101
|
+
return bindings
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def install_shift_enter_key_sequence_overrides() -> None:
|
|
105
|
+
from prompt_toolkit.input import vt100_parser
|
|
106
|
+
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
|
|
107
|
+
|
|
108
|
+
for sequence in SHIFT_ENTER_SEQUENCES:
|
|
109
|
+
ANSI_SEQUENCES[sequence] = (Keys.Escape, Keys.ControlM)
|
|
110
|
+
prefix_cache = getattr(vt100_parser, "_IS_PREFIX_OF_LONGER_MATCH_CACHE", None)
|
|
111
|
+
if hasattr(prefix_cache, "clear"):
|
|
112
|
+
prefix_cache.clear()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def prompt_for_input(
|
|
116
|
+
session: PromptSession[str],
|
|
117
|
+
message: AnyFormattedText | None = None,
|
|
118
|
+
bottom_toolbar: AnyFormattedText | None = None,
|
|
119
|
+
) -> str:
|
|
120
|
+
prompt_message = PROMPT_MESSAGE if message is None else message
|
|
121
|
+
return session.prompt(
|
|
122
|
+
prompt_message,
|
|
123
|
+
placeholder=PROMPT_PLACEHOLDER,
|
|
124
|
+
bottom_toolbar=PROMPT_TOOLBAR if bottom_toolbar is None else bottom_toolbar,
|
|
125
|
+
).strip()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_prompt_toolbar(context_status: str = "") -> AnyFormattedText:
|
|
129
|
+
if not context_status:
|
|
130
|
+
return PROMPT_TOOLBAR
|
|
131
|
+
return [
|
|
132
|
+
("class:toolbar.context", context_status),
|
|
133
|
+
("class:toolbar.separator", " · "),
|
|
134
|
+
("class:toolbar.help", PROMPT_TOOLBAR_HELP),
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def format_selected_skills_status(skills: list[SkillInfo]) -> str:
|
|
139
|
+
names = [skill.name for skill in skills if skill.name]
|
|
140
|
+
if not names:
|
|
141
|
+
return ""
|
|
142
|
+
return f"⚡ {', '.join(names)}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_skill_selected(skills: list[SkillInfo], skill: SkillInfo) -> bool:
|
|
146
|
+
return any(item.name == skill.name for item in skills)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def add_unique_skill(skills: list[SkillInfo], skill: SkillInfo) -> list[SkillInfo]:
|
|
150
|
+
if is_skill_selected(skills, skill):
|
|
151
|
+
return skills
|
|
152
|
+
return [*skills, skill]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def toggle_skill_selection(skills: list[SkillInfo], skill: SkillInfo) -> list[SkillInfo]:
|
|
156
|
+
if is_skill_selected(skills, skill):
|
|
157
|
+
return [item for item in skills if item.name != skill.name]
|
|
158
|
+
return [*skills, skill]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def remove_current_slash_token(state: PromptBufferState) -> PromptBufferState:
|
|
162
|
+
start = state.cursor
|
|
163
|
+
while start > 0 and not state.text[start - 1].isspace():
|
|
164
|
+
start -= 1
|
|
165
|
+
|
|
166
|
+
token = state.text[start : state.cursor]
|
|
167
|
+
if not token.startswith("/"):
|
|
168
|
+
return state
|
|
169
|
+
|
|
170
|
+
text = f"{state.text[:start]}{state.text[state.cursor:]}"
|
|
171
|
+
return PromptBufferState(text=text, cursor=start)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def render_buffer_with_cursor(
|
|
175
|
+
state: PromptBufferState,
|
|
176
|
+
is_focused: bool,
|
|
177
|
+
placeholder: str | None = None,
|
|
178
|
+
) -> str:
|
|
179
|
+
text = state.text or ""
|
|
180
|
+
cursor = min(max(state.cursor, 0), len(text))
|
|
181
|
+
before = text[:cursor]
|
|
182
|
+
at = text[cursor] if cursor < len(text) else None
|
|
183
|
+
after = text[cursor + 1 :]
|
|
184
|
+
|
|
185
|
+
if not text and placeholder:
|
|
186
|
+
return _dim(f" {placeholder}")
|
|
187
|
+
|
|
188
|
+
if not is_focused:
|
|
189
|
+
return f"{text} " if text.endswith("\n") else text
|
|
190
|
+
|
|
191
|
+
if at is None:
|
|
192
|
+
return before + _inverse(" ")
|
|
193
|
+
if at == "\n":
|
|
194
|
+
return before + _inverse(" ") + "\n" + after
|
|
195
|
+
return before + _inverse(at) + after
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_prompt_cursor_placement(
|
|
199
|
+
state: PromptBufferState,
|
|
200
|
+
screen_width: int,
|
|
201
|
+
prefix_width: int,
|
|
202
|
+
footer_text: str,
|
|
203
|
+
) -> PromptCursorPlacement:
|
|
204
|
+
width = max(1, screen_width)
|
|
205
|
+
cursor = min(max(state.cursor, 0), len(state.text))
|
|
206
|
+
before_cursor = state.text[:cursor]
|
|
207
|
+
at = state.text[cursor] if cursor < len(state.text) else None
|
|
208
|
+
display_text = (
|
|
209
|
+
before_cursor
|
|
210
|
+
+ (" " if at is None or at == "\n" else at)
|
|
211
|
+
+ ("\n" if at == "\n" else "")
|
|
212
|
+
+ ("" if at is None else state.text[cursor + 1 :])
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
cursor_position = measure_text_position(before_cursor, width=width, initial_column=prefix_width)
|
|
216
|
+
prompt_rows = measure_text_rows(display_text, width=width, initial_column=prefix_width)
|
|
217
|
+
footer_rows = 1 + measure_text_rows(footer_text, width=width, initial_column=0)
|
|
218
|
+
return PromptCursorPlacement(
|
|
219
|
+
rows_up=(prompt_rows - 1 - cursor_position.row) + footer_rows + 1,
|
|
220
|
+
column=cursor_position.column,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class TextPosition:
|
|
226
|
+
row: int
|
|
227
|
+
column: int
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def measure_text_rows(text: str, *, width: int, initial_column: int) -> int:
|
|
231
|
+
return measure_text_position(text, width=width, initial_column=initial_column).row + 1
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def measure_text_position(text: str, *, width: int, initial_column: int) -> TextPosition:
|
|
235
|
+
effective_width = max(1, width)
|
|
236
|
+
row = 0
|
|
237
|
+
column = min(initial_column, effective_width - 1)
|
|
238
|
+
|
|
239
|
+
for char in text:
|
|
240
|
+
if char == "\n":
|
|
241
|
+
row += 1
|
|
242
|
+
column = min(initial_column, effective_width - 1)
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
char_columns = text_width(char)
|
|
246
|
+
if column + char_columns > effective_width:
|
|
247
|
+
row += 1
|
|
248
|
+
column = min(initial_column, effective_width - 1)
|
|
249
|
+
column += char_columns
|
|
250
|
+
if column >= effective_width:
|
|
251
|
+
row += 1
|
|
252
|
+
column = min(initial_column, effective_width - 1)
|
|
253
|
+
|
|
254
|
+
return TextPosition(row=row, column=column)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def text_width(value: str) -> int:
|
|
258
|
+
return sum(character_width(char) for char in normalize("NFC", value))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def character_width(char: str) -> int:
|
|
262
|
+
code_point = ord(char)
|
|
263
|
+
if code_point == 0 or code_point < 32 or (0x7F <= code_point < 0xA0):
|
|
264
|
+
return 0
|
|
265
|
+
if 0x300 <= code_point <= 0x36F:
|
|
266
|
+
return 0
|
|
267
|
+
if (
|
|
268
|
+
(0x1100 <= code_point <= 0x115F)
|
|
269
|
+
or (0x2E80 <= code_point <= 0xA4CF)
|
|
270
|
+
or (0xAC00 <= code_point <= 0xD7A3)
|
|
271
|
+
or (0xF900 <= code_point <= 0xFAFF)
|
|
272
|
+
or (0xFE10 <= code_point <= 0xFE19)
|
|
273
|
+
or (0xFE30 <= code_point <= 0xFE6F)
|
|
274
|
+
or (0xFF00 <= code_point <= 0xFF60)
|
|
275
|
+
or (0xFFE0 <= code_point <= 0xFFE6)
|
|
276
|
+
):
|
|
277
|
+
return 2
|
|
278
|
+
return 1
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _inverse(value: str) -> str:
|
|
282
|
+
return f"\x1b[7m{value}\x1b[0m"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _dim(value: str) -> str:
|
|
286
|
+
return f"\x1b[2m{value}\x1b[0m"
|
deepy/ui/session_list.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Protocol, TypeVar
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionChoice(Protocol):
|
|
12
|
+
id: str
|
|
13
|
+
updated_at: int
|
|
14
|
+
active_tokens: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SessionListWindow:
|
|
19
|
+
safe_index: int
|
|
20
|
+
scroll_offset: int
|
|
21
|
+
max_visible: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_session_title(value: str | None, max_chars: int = 70) -> str:
|
|
25
|
+
title = " ".join((value or "Untitled").split()).strip() or "Untitled"
|
|
26
|
+
return _truncate(title, max_chars)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def max_visible_sessions(rows: int) -> int:
|
|
30
|
+
reserved_lines = 8
|
|
31
|
+
lines_per_session = 3
|
|
32
|
+
available_lines = max(0, min(rows, 30) - reserved_lines)
|
|
33
|
+
return max(1, available_lines // lines_per_session)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def session_list_window(
|
|
37
|
+
*,
|
|
38
|
+
session_count: int,
|
|
39
|
+
selected_index: int,
|
|
40
|
+
rows: int,
|
|
41
|
+
) -> SessionListWindow:
|
|
42
|
+
visible = max_visible_sessions(rows)
|
|
43
|
+
if session_count <= 0:
|
|
44
|
+
return SessionListWindow(safe_index=0, scroll_offset=0, max_visible=visible)
|
|
45
|
+
safe_index = min(max(selected_index, 0), session_count - 1)
|
|
46
|
+
scroll_offset = 0 if safe_index < visible else safe_index - visible + 1
|
|
47
|
+
return SessionListWindow(
|
|
48
|
+
safe_index=safe_index,
|
|
49
|
+
scroll_offset=scroll_offset,
|
|
50
|
+
max_visible=visible,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def visible_sessions(
|
|
55
|
+
sessions: list[T],
|
|
56
|
+
*,
|
|
57
|
+
selected_index: int,
|
|
58
|
+
rows: int,
|
|
59
|
+
) -> list[T]:
|
|
60
|
+
window = session_list_window(
|
|
61
|
+
session_count=len(sessions),
|
|
62
|
+
selected_index=selected_index,
|
|
63
|
+
rows=rows,
|
|
64
|
+
)
|
|
65
|
+
return sessions[window.scroll_offset : window.scroll_offset + window.max_visible]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def format_session_choice(entry: SessionChoice, index: int) -> str:
|
|
69
|
+
return (
|
|
70
|
+
f"{index}. {entry.id} updated={entry.updated_at} "
|
|
71
|
+
f"history_tokens={entry.active_tokens}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def format_session_choices(entries: Sequence[SessionChoice], *, max_entries: int = 10) -> str:
|
|
76
|
+
if not entries:
|
|
77
|
+
return "No sessions found."
|
|
78
|
+
lines = [
|
|
79
|
+
format_session_choice(entry, index)
|
|
80
|
+
for index, entry in enumerate(entries[:max_entries], 1)
|
|
81
|
+
]
|
|
82
|
+
remaining = len(entries) - len(lines)
|
|
83
|
+
if remaining > 0:
|
|
84
|
+
lines.append(f"...and {remaining} more.")
|
|
85
|
+
return "\n".join(lines)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_session_selection(
|
|
89
|
+
entries: Sequence[SessionChoice],
|
|
90
|
+
selection: str,
|
|
91
|
+
) -> SessionChoice | None:
|
|
92
|
+
value = selection.strip()
|
|
93
|
+
if not value:
|
|
94
|
+
return None
|
|
95
|
+
if value.isdigit():
|
|
96
|
+
index = int(value) - 1
|
|
97
|
+
if 0 <= index < len(entries):
|
|
98
|
+
return entries[index]
|
|
99
|
+
exact = [entry for entry in entries if entry.id == value]
|
|
100
|
+
if len(exact) == 1:
|
|
101
|
+
return exact[0]
|
|
102
|
+
prefix = [entry for entry in entries if entry.id.startswith(value)]
|
|
103
|
+
if len(prefix) == 1:
|
|
104
|
+
return prefix[0]
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def move_session_selection(
|
|
109
|
+
*,
|
|
110
|
+
selected_index: int,
|
|
111
|
+
session_count: int,
|
|
112
|
+
action: str,
|
|
113
|
+
rows: int,
|
|
114
|
+
) -> int:
|
|
115
|
+
if session_count <= 0:
|
|
116
|
+
return 0
|
|
117
|
+
visible = max_visible_sessions(rows)
|
|
118
|
+
if action == "up":
|
|
119
|
+
next_index = selected_index - 1
|
|
120
|
+
elif action == "down":
|
|
121
|
+
next_index = selected_index + 1
|
|
122
|
+
elif action == "page_up":
|
|
123
|
+
next_index = selected_index - visible
|
|
124
|
+
elif action == "page_down":
|
|
125
|
+
next_index = selected_index + visible
|
|
126
|
+
elif action == "home":
|
|
127
|
+
next_index = 0
|
|
128
|
+
elif action == "end":
|
|
129
|
+
next_index = session_count - 1
|
|
130
|
+
else:
|
|
131
|
+
next_index = selected_index
|
|
132
|
+
return min(max(next_index, 0), session_count - 1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _truncate(value: str, max_chars: int) -> str:
|
|
136
|
+
if max_chars <= 0:
|
|
137
|
+
return ""
|
|
138
|
+
if len(value) <= max_chars:
|
|
139
|
+
return value
|
|
140
|
+
return value[:max_chars] + "…"
|