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.
Files changed (69) hide show
  1. deepy/__init__.py +9 -0
  2. deepy/__main__.py +7 -0
  3. deepy/cli.py +413 -0
  4. deepy/config/__init__.py +21 -0
  5. deepy/config/settings.py +237 -0
  6. deepy/data/__init__.py +1 -0
  7. deepy/data/tools/AskUserQuestion.md +10 -0
  8. deepy/data/tools/WebFetch.md +9 -0
  9. deepy/data/tools/WebSearch.md +9 -0
  10. deepy/data/tools/__init__.py +1 -0
  11. deepy/data/tools/bash.md +7 -0
  12. deepy/data/tools/edit.md +13 -0
  13. deepy/data/tools/modify.md +17 -0
  14. deepy/data/tools/read.md +8 -0
  15. deepy/data/tools/write.md +12 -0
  16. deepy/errors.py +63 -0
  17. deepy/llm/__init__.py +13 -0
  18. deepy/llm/agent.py +31 -0
  19. deepy/llm/context.py +109 -0
  20. deepy/llm/events.py +187 -0
  21. deepy/llm/model_capabilities.py +7 -0
  22. deepy/llm/provider.py +81 -0
  23. deepy/llm/replay.py +120 -0
  24. deepy/llm/runner.py +412 -0
  25. deepy/llm/thinking.py +30 -0
  26. deepy/prompts/__init__.py +6 -0
  27. deepy/prompts/compact.py +100 -0
  28. deepy/prompts/rules.py +24 -0
  29. deepy/prompts/runtime_context.py +98 -0
  30. deepy/prompts/system.py +72 -0
  31. deepy/prompts/tool_docs.py +21 -0
  32. deepy/sessions/__init__.py +17 -0
  33. deepy/sessions/jsonl.py +306 -0
  34. deepy/sessions/manager.py +202 -0
  35. deepy/skills.py +202 -0
  36. deepy/status.py +65 -0
  37. deepy/tools/__init__.py +6 -0
  38. deepy/tools/agents.py +343 -0
  39. deepy/tools/builtin.py +2113 -0
  40. deepy/tools/file_state.py +85 -0
  41. deepy/tools/result.py +54 -0
  42. deepy/tools/shell_utils.py +83 -0
  43. deepy/ui/__init__.py +5 -0
  44. deepy/ui/app.py +118 -0
  45. deepy/ui/ask_user_question.py +182 -0
  46. deepy/ui/exit_summary.py +142 -0
  47. deepy/ui/loading_text.py +87 -0
  48. deepy/ui/markdown.py +152 -0
  49. deepy/ui/message_view.py +546 -0
  50. deepy/ui/prompt_buffer.py +176 -0
  51. deepy/ui/prompt_input.py +286 -0
  52. deepy/ui/session_list.py +140 -0
  53. deepy/ui/session_picker.py +179 -0
  54. deepy/ui/slash_commands.py +67 -0
  55. deepy/ui/styles.py +21 -0
  56. deepy/ui/terminal.py +959 -0
  57. deepy/ui/thinking_state.py +29 -0
  58. deepy/ui/welcome.py +195 -0
  59. deepy/update_check.py +195 -0
  60. deepy/usage.py +192 -0
  61. deepy/utils/__init__.py +15 -0
  62. deepy/utils/debug_logger.py +62 -0
  63. deepy/utils/error_logger.py +107 -0
  64. deepy/utils/json.py +29 -0
  65. deepy/utils/notify.py +66 -0
  66. deepy_cli-0.1.1.dist-info/METADATA +205 -0
  67. deepy_cli-0.1.1.dist-info/RECORD +69 -0
  68. deepy_cli-0.1.1.dist-info/WHEEL +4 -0
  69. 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))
@@ -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"
@@ -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] + "…"