coderio 0.4.0__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 (97) hide show
  1. coderio/__init__.py +14 -0
  2. coderio/agent/__init__.py +5 -0
  3. coderio/agent/_deepagents_compat.py +139 -0
  4. coderio/agent/command_review.py +140 -0
  5. coderio/agent/deep_loop.py +758 -0
  6. coderio/agent/harness.py +768 -0
  7. coderio/agent/harness_middleware.py +293 -0
  8. coderio/agent/permission_middleware.py +52 -0
  9. coderio/agent/prompts.py +304 -0
  10. coderio/agent/skill_tool.py +70 -0
  11. coderio/agent/state.py +144 -0
  12. coderio/agent/stream.py +93 -0
  13. coderio/cli/__init__.py +0 -0
  14. coderio/cli/app.py +257 -0
  15. coderio/cli/commands.py +339 -0
  16. coderio/cli/credentials.py +70 -0
  17. coderio/cli/mcp_cmd.py +227 -0
  18. coderio/cli/multimodal.py +94 -0
  19. coderio/cli/onboarding.py +270 -0
  20. coderio/cli/providers.py +113 -0
  21. coderio/cli/render.py +28 -0
  22. coderio/cli/repl.py +189 -0
  23. coderio/cli/run_cmd.py +171 -0
  24. coderio/cli/skills_cmd.py +77 -0
  25. coderio/cli/stream.py +224 -0
  26. coderio/cli/tui.py +1584 -0
  27. coderio/cli/tui_onboarding.py +465 -0
  28. coderio/cli/tui_screens.py +334 -0
  29. coderio/cli/tui_widgets.py +453 -0
  30. coderio/config/__init__.py +21 -0
  31. coderio/config/bootstrap.py +38 -0
  32. coderio/config/loader.py +273 -0
  33. coderio/config/models.py +193 -0
  34. coderio/config/trust.py +136 -0
  35. coderio/llm/__init__.py +3 -0
  36. coderio/llm/factory.py +164 -0
  37. coderio/llm/probe.py +153 -0
  38. coderio/mcp_loader.py +274 -0
  39. coderio/session/__init__.py +3 -0
  40. coderio/session/message.py +79 -0
  41. coderio/session/store.py +244 -0
  42. coderio/skills/__init__.py +4 -0
  43. coderio/skills/bundled/executing-plans/SKILL.md +15 -0
  44. coderio/skills/lion-skills/LICENSE +21 -0
  45. coderio/skills/lion-skills/README.md +114 -0
  46. coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
  47. coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
  48. coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
  49. coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
  50. coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
  51. coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
  52. coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
  53. coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
  54. coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
  55. coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
  56. coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
  57. coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
  58. coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
  59. coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
  60. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
  61. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
  62. coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
  63. coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
  64. coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
  65. coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
  66. coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
  67. coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
  68. coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
  69. coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
  70. coderio/skills/models.py +66 -0
  71. coderio/skills/parser.py +50 -0
  72. coderio/skills/store.py +102 -0
  73. coderio/tools/__init__.py +94 -0
  74. coderio/tools/base.py +55 -0
  75. coderio/tools/bash.py +168 -0
  76. coderio/tools/command_policy.py +364 -0
  77. coderio/tools/edit_file.py +55 -0
  78. coderio/tools/glob_tool.py +21 -0
  79. coderio/tools/grep_tool.py +90 -0
  80. coderio/tools/linux_sandbox.py +196 -0
  81. coderio/tools/list_dir.py +58 -0
  82. coderio/tools/multi_edit.py +62 -0
  83. coderio/tools/note.py +72 -0
  84. coderio/tools/permission.py +214 -0
  85. coderio/tools/read_file.py +51 -0
  86. coderio/tools/sandbox_runner.py +113 -0
  87. coderio/tools/todo.py +71 -0
  88. coderio/tools/web_fetch.py +188 -0
  89. coderio/tools/web_search.py +49 -0
  90. coderio/tools/win_job.py +214 -0
  91. coderio/tools/win_sandbox.py +639 -0
  92. coderio/tools/write_file.py +25 -0
  93. coderio-0.4.0.dist-info/METADATA +490 -0
  94. coderio-0.4.0.dist-info/RECORD +97 -0
  95. coderio-0.4.0.dist-info/WHEEL +4 -0
  96. coderio-0.4.0.dist-info/entry_points.txt +2 -0
  97. coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,334 @@
1
+ """TUI picker screens — extracted from tui.py for modularity.
2
+
3
+ Contains the three modal picker screens used by the TUI:
4
+ - ProfilePickerScreen: /profile (switch saved [[profiles]])
5
+ - ModePickerScreen: /mode with no argument (switch permission mode)
6
+ - SessionPickerScreen: /resume (resume or delete recent sessions)
7
+
8
+ All three are ModalScreen subclasses — pure popups with no top-level coupling
9
+ to the rest of coderio, so this module can be imported standalone.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from textual.app import ComposeResult
17
+ from textual.binding import Binding
18
+ from textual.containers import Vertical
19
+ from textual.screen import ModalScreen
20
+ from textual.widgets import Input, ListItem, ListView, Static
21
+
22
+
23
+ class ProfilePickerScreen(ModalScreen[str | None]):
24
+ """Interactive profile picker (/profile).
25
+
26
+ Lists all saved [[profiles]] from config.toml as a ListView — each row shows
27
+ the profile name with its provider/model as a dim subtitle, and the active
28
+ profile is marked ★. ↑↓ navigates, Enter switches (dismisses with the chosen
29
+ profile name), Esc cancels (dismisses None). Mirrors the SessionPickerScreen
30
+ UX so both pickers feel the same.
31
+ """
32
+
33
+ CSS = """
34
+ ProfilePickerScreen { align: center middle; }
35
+ #profile-box {
36
+ width: 70%; height: auto; max-height: 70%; border: round $accent;
37
+ background: $surface; padding: 1 2;
38
+ }
39
+ #profile-title { text-align: center; margin-bottom: 1; }
40
+ #profile-list { height: auto; max-height: 16; }
41
+ ProfilePickerScreen ListItem { padding: 0 1; }
42
+ ProfilePickerScreen ListItem > Widget :hover { background: $boost; }
43
+ """
44
+
45
+ BINDINGS = [
46
+ Binding("escape", "cancel", "取消", show=True),
47
+ ]
48
+
49
+ def __init__(self, profiles: list, active_name: str = "") -> None:
50
+ super().__init__()
51
+ self._profiles = profiles
52
+ self._active_name = active_name
53
+
54
+ def compose(self) -> ComposeResult:
55
+ with Vertical(id="profile-box"):
56
+ yield Static(
57
+ "[bold magenta]切换配置[/bold magenta] ↑↓ 选择 · Enter 切换 · Esc 取消",
58
+ id="profile-title",
59
+ )
60
+ yield ListView(id="profile-list")
61
+
62
+ def on_mount(self) -> None:
63
+ lv = self.query_one("#profile-list", ListView)
64
+ for p in self._profiles:
65
+ star = "★ " if p.name == self._active_name else " "
66
+ lv.append(
67
+ ListItem(
68
+ Static(f"{star}{p.name} [dim]{p.provider_id} · {p.model}[/dim]"),
69
+ name=p.name,
70
+ )
71
+ )
72
+ try:
73
+ lv.index = 0
74
+ except Exception:
75
+ pass
76
+ lv.focus()
77
+
78
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
79
+ """Enter on a row → switch to that profile."""
80
+ self.dismiss(event.item.name)
81
+
82
+ def action_cancel(self) -> None:
83
+ self.dismiss(None)
84
+
85
+
86
+ class ModePickerScreen(ModalScreen[str | None]):
87
+ """Visual permission-mode picker (/mode with no argument).
88
+
89
+ Lists the three modes (confirm / plan / auto) as a ListView with the
90
+ current mode marked ★. ↑↓ navigates, Enter selects (dismisses with the
91
+ mode name), Esc cancels. Same UX pattern as ProfilePickerScreen.
92
+ """
93
+
94
+ CSS = """
95
+ ModePickerScreen { align: center middle; }
96
+ #mode-box {
97
+ width: 60%; height: auto; max-height: 60%; border: round $accent;
98
+ background: $surface; padding: 1 2;
99
+ }
100
+ #mode-title { text-align: center; margin-bottom: 1; }
101
+ #mode-list { height: auto; max-height: 8; }
102
+ ModePickerScreen ListItem { padding: 0 1; }
103
+ ModePickerScreen ListItem > Widget :hover { background: $boost; }
104
+ """
105
+
106
+ BINDINGS = [
107
+ Binding("escape", "cancel", "取消", show=True),
108
+ ]
109
+
110
+ # mode -> human-readable description shown as a dim subtitle.
111
+ _MODE_INFO = {
112
+ "plan": "只读模式,阻止所有写操作(最安全)",
113
+ "confirm": "每次写操作前确认",
114
+ "auto_edit": "自动编辑文件,bash/网络仍需确认",
115
+ "full": "全自动,不确认任何操作(需信任)",
116
+ }
117
+
118
+ def __init__(self, active_mode: str = "") -> None:
119
+ super().__init__()
120
+ self._active_mode = active_mode
121
+
122
+ def compose(self) -> ComposeResult:
123
+ with Vertical(id="mode-box"):
124
+ yield Static(
125
+ "[bold magenta]切换权限模式[/bold magenta] ↑↓ 选择 · Enter 切换 · Esc 取消",
126
+ id="mode-title",
127
+ )
128
+ yield ListView(id="mode-list")
129
+
130
+ def on_mount(self) -> None:
131
+ lv = self.query_one("#mode-list", ListView)
132
+ for mode in ("plan", "confirm", "auto_edit", "full"):
133
+ star = "★ " if mode == self._active_mode else " "
134
+ desc = self._MODE_INFO.get(mode, "")
135
+ lv.append(
136
+ ListItem(
137
+ Static(f"{star}{mode} [dim]{desc}[/dim]"),
138
+ name=mode,
139
+ )
140
+ )
141
+ try:
142
+ lv.index = 0
143
+ except Exception:
144
+ pass
145
+ lv.focus()
146
+
147
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
148
+ """Enter on a row → switch to that mode."""
149
+ self.dismiss(event.item.name)
150
+
151
+ def action_cancel(self) -> None:
152
+ self.dismiss(None)
153
+
154
+
155
+ class SessionPickerScreen(ModalScreen[str | None]):
156
+ """Interactive session picker (Claude-Code-style /resume).
157
+
158
+ Shows recent sessions as a scrollable list — each row has the first user
159
+ message (so the user recognizes the conversation by what they asked, not by
160
+ an opaque id), the message count, and the time. ↑↓ navigates, Enter resumes,
161
+ Esc cancels. Typing filters the list by the summary text. Dismisses with the
162
+ chosen session id (string) or None (cancelled).
163
+ """
164
+
165
+ CSS = """
166
+ SessionPickerScreen {
167
+ align: center middle;
168
+ }
169
+ #picker-box {
170
+ width: 80%; height: 70%; border: thick $accent; background: $surface;
171
+ padding: 1 2;
172
+ }
173
+ #picker-title { text-align: center; color: $accent; margin-bottom: 1; }
174
+ #picker-filter {
175
+ dock: bottom; margin-top: 1; border: round $accent 50%;
176
+ }
177
+ #picker-filter:focus { border: round $accent; }
178
+ #picker-list { height: 1fr; border: none; }
179
+ .picker-row { padding: 0 1; }
180
+ .picker-row:first-child { margin-top: 0; }
181
+ .picker-summary { color: $text; }
182
+ .picker-meta { color: $text-muted; }
183
+ """
184
+
185
+ BINDINGS = [
186
+ Binding("escape", "cancel", "取消", show=True),
187
+ Binding("delete", "delete_selected", "删除", show=True),
188
+ ]
189
+
190
+ def __init__(self, summaries: list[dict], save_dir: str = "", active_session_id: str = "") -> None:
191
+ super().__init__()
192
+ self._all = summaries # full list; filtered view derived on typing
193
+ self._filter = ""
194
+ self._delete_confirm_sid: str | None = None # pending delete confirmation
195
+ # The directory where session files actually live. Must match the
196
+ # source that populated `summaries` — hardcoding ~/.coderio/sessions
197
+ # would delete the wrong files when a custom save_dir is configured.
198
+ self._save_dir = save_dir
199
+ self._active_session_id = active_session_id # cannot be deleted
200
+
201
+ def compose(self) -> ComposeResult:
202
+ with Vertical(id="picker-box"):
203
+ yield Static(
204
+ "[bold]恢复会话[/bold] ↑↓ 选择 · Enter 恢复 · Del 删除(按2次确认) · Esc 取消 · 输入过滤",
205
+ id="picker-title",
206
+ )
207
+ yield ListView(id="picker-list")
208
+ yield Input(placeholder="输入关键字过滤(首条消息 / 时间)", id="picker-filter")
209
+
210
+ def on_mount(self) -> None:
211
+ self._populate()
212
+ # Focus the ListView (not the filter input) so ↑↓ navigation works
213
+ # immediately. The filter input still receives keystrokes via on_key
214
+ # forwarding when the user starts typing.
215
+ lv = self.query_one("#picker-list", ListView)
216
+ try:
217
+ lv.index = 0
218
+ except Exception:
219
+ pass
220
+ lv.focus()
221
+
222
+ def on_key(self, event) -> None:
223
+ """Forward printable keys to the filter input; let ListView handle nav."""
224
+ # Cancel pending delete on any key other than Del.
225
+ if event.key != "delete" and self._delete_confirm_sid is not None:
226
+ self._delete_confirm_sid = None
227
+ inp = self.query_one("#picker-filter", Input)
228
+ if event.key in ("up", "down", "enter", "escape", "delete", "pageup", "pagedown"):
229
+ return # ListView / bindings handle these natively when it has focus
230
+ # Printable character → route to filter input for live search
231
+ if event.character and len(event.character) == 1 and event.character.isprintable():
232
+ inp.focus()
233
+ inp.value += event.character
234
+ event.prevent_default()
235
+ elif event.key == "backspace":
236
+ inp.focus()
237
+ if inp.value:
238
+ inp.value = inp.value[:-1]
239
+ event.prevent_default()
240
+
241
+ def _populate(self) -> None:
242
+ """Rebuild the list from the (filtered) summaries."""
243
+ lv = self.query_one("#picker-list", ListView)
244
+ lv.clear()
245
+ f = self._filter.lower()
246
+ for s in self._all:
247
+ label = s["first_user"] or "(空会话)"
248
+ if f and f not in label.lower() and f not in s["mtime"].lower() and f not in s["id"].lower():
249
+ continue
250
+ row = Static(
251
+ f"[{s['mtime']}] {label}\n"
252
+ f" [dim]{s['message_count']} 条消息 · {s.get('model') or '?'} · {s['id']}[/dim]",
253
+ classes="picker-row",
254
+ )
255
+ lv.append(ListItem(row, name=s["id"]))
256
+
257
+ def on_input_changed(self, event: Input.Changed) -> None:
258
+ if event.input.id == "picker-filter":
259
+ self._filter = event.value
260
+ self._populate()
261
+
262
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
263
+ """Enter on a row → resume that session."""
264
+ self.dismiss(event.item.name)
265
+
266
+ def action_cancel(self) -> None:
267
+ self.dismiss(None)
268
+
269
+ def action_delete_selected(self) -> None:
270
+ """Delete the currently highlighted session (jsonl + sqlite).
271
+
272
+ Two-step confirmation: first Del selects the item for deletion (shows
273
+ a confirmation prompt), second Del on the SAME item executes. Moving
274
+ the cursor or pressing any other key cancels the pending delete.
275
+ """
276
+ lv = self.query_one("#picker-list", ListView)
277
+ if not lv.children or lv.index is None:
278
+ return
279
+ item = lv.children[lv.index]
280
+ sid = item.name or ""
281
+ if not sid:
282
+ return
283
+ # Protect the currently active session — deleting it would corrupt
284
+ # the running conversation (the Session object still holds a file
285
+ # handle, and the next append would recreate a file without history).
286
+ if sid == self._active_session_id:
287
+ self.app.bell()
288
+ return
289
+ return
290
+
291
+ # Two-step confirmation: if there's a pending delete for THIS sid,
292
+ # execute it. Otherwise, mark it pending and wait for second Del.
293
+ if self._delete_confirm_sid == sid:
294
+ self._delete_confirm_sid = None # clear before executing
295
+ self._do_delete(lv, sid)
296
+ else:
297
+ # First Del — mark pending. User must press Del again to confirm.
298
+ self._delete_confirm_sid = sid
299
+ self.app.bell()
300
+
301
+ def _do_delete(self, lv: ListView, sid: str) -> None:
302
+ """Actually delete the session files and update the list."""
303
+ # Use the actual save_dir (from config), not a hardcoded path.
304
+
305
+ # Use the actual save_dir (from config), not a hardcoded path.
306
+ if self._save_dir:
307
+ sessions_dir = Path(self._save_dir).expanduser()
308
+ else:
309
+ sessions_dir = Path.home() / ".coderio" / "sessions"
310
+ deleted_any = False
311
+ delete_failed = False
312
+ for suffix in (".jsonl", ".sqlite", ".sqlite-wal", ".sqlite-shm"):
313
+ p = sessions_dir / f"{sid}{suffix}"
314
+ if p.exists():
315
+ try:
316
+ p.unlink()
317
+ deleted_any = True
318
+ except Exception:
319
+ delete_failed = True
320
+ # Only remove from UI if deletion succeeded. If it failed, keep the
321
+ # entry so the user knows it's still on disk (no false success).
322
+ if deleted_any and not delete_failed:
323
+ self._all = [s for s in self._all if s["id"] != sid]
324
+ self._populate()
325
+ if not self._all:
326
+ self.dismiss(None) # no sessions left → close picker
327
+ else:
328
+ try:
329
+ lv.index = 0
330
+ except Exception:
331
+ pass
332
+ elif delete_failed:
333
+ # Show a brief error notice without closing the picker.
334
+ self.app.bell()