pal-code 0.1.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 (282) hide show
  1. pal/__init__.py +29 -0
  2. pal/__main__.py +3 -0
  3. pal/access_manager/__init__.py +16 -0
  4. pal/access_manager/components/__init__.py +14 -0
  5. pal/access_manager/components/access_control.py +41 -0
  6. pal/access_manager/components/base.py +39 -0
  7. pal/access_manager/components/intervention.py +31 -0
  8. pal/access_manager/components/privilege_registry.py +30 -0
  9. pal/access_manager/pipeline.py +68 -0
  10. pal/access_manager/utils/__init__.py +2 -0
  11. pal/agent_constraints/__init__.py +3 -0
  12. pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
  13. pal/agent_constraints/components/__init__.py +3 -0
  14. pal/agent_constraints/components/constraints.py +22 -0
  15. pal/agent_constraints/pipeline.py +22 -0
  16. pal/agent_constraints/utils/__init__.py +2 -0
  17. pal/agents/__init__.py +12 -0
  18. pal/agents/coding.py +14 -0
  19. pal/agents/docs.py +15 -0
  20. pal/chat/__init__.py +4 -0
  21. pal/chat/demo.py +231 -0
  22. pal/chat/providers.py +23 -0
  23. pal/chat/service.py +591 -0
  24. pal/chat/services/__init__.py +26 -0
  25. pal/chat/services/base.py +142 -0
  26. pal/chat/services/data.py +142 -0
  27. pal/chat/services/media.py +123 -0
  28. pal/chat/services/web.py +60 -0
  29. pal/chat/sessions.py +108 -0
  30. pal/chat/tools.py +446 -0
  31. pal/chat/uploads.py +81 -0
  32. pal/cli.py +502 -0
  33. pal/compression/__init__.py +12 -0
  34. pal/compression/components/__init__.py +64 -0
  35. pal/compression/components/adverbs.py +20 -0
  36. pal/compression/components/hedges.py +54 -0
  37. pal/compression/components/interjections.py +20 -0
  38. pal/compression/components/minifier.py +56 -0
  39. pal/compression/components/output_compressor.py +13 -0
  40. pal/compression/components/pleonasms.py +54 -0
  41. pal/compression/components/punctuations.py +36 -0
  42. pal/compression/components/stopwords.py +62 -0
  43. pal/compression/history.py +346 -0
  44. pal/compression/pipeline.py +58 -0
  45. pal/compression/utils/__init__.py +30 -0
  46. pal/compression/utils/negations.py +18 -0
  47. pal/compression/utils/nltk_setup.py +20 -0
  48. pal/compression/utils/pronouns.py +27 -0
  49. pal/compression/utils/proselint_setup.py +9 -0
  50. pal/compression/utils/spacy_setup.py +22 -0
  51. pal/config/default.yaml +163 -0
  52. pal/config.py +504 -0
  53. pal/context_manager/__init__.py +7 -0
  54. pal/context_manager/chat_context.py +117 -0
  55. pal/context_manager/components/__init__.py +5 -0
  56. pal/context_manager/components/assembler.py +75 -0
  57. pal/context_manager/components/context_cache.py +45 -0
  58. pal/context_manager/components/kv_bridge.py +16 -0
  59. pal/context_manager/components/pager.py +48 -0
  60. pal/context_manager/components/simple_context_manager.py +56 -0
  61. pal/context_manager/components/turns.py +88 -0
  62. pal/context_manager/pipeline.py +27 -0
  63. pal/context_manager/utils/__init__.py +18 -0
  64. pal/context_manager/utils/hashing.py +5 -0
  65. pal/context_manager/utils/relevance.py +59 -0
  66. pal/context_manager/utils/storage.py +37 -0
  67. pal/documents/__init__.py +4 -0
  68. pal/documents/components/__init__.py +0 -0
  69. pal/documents/components/chunker.py +79 -0
  70. pal/documents/components/index.py +155 -0
  71. pal/documents/utils/__init__.py +1 -0
  72. pal/errors.py +40 -0
  73. pal/input_converters/__init__.py +8 -0
  74. pal/input_converters/components/__init__.py +0 -0
  75. pal/input_converters/components/base.py +38 -0
  76. pal/input_converters/components/pandoc_converter.py +87 -0
  77. pal/input_converters/components/pdf_converter.py +46 -0
  78. pal/input_converters/components/pptx_converter.py +52 -0
  79. pal/input_converters/components/xlsx_converter.py +38 -0
  80. pal/input_converters/pipeline.py +72 -0
  81. pal/input_converters/utils/__init__.py +1 -0
  82. pal/input_converters/workspace.py +56 -0
  83. pal/input_savers/INPUT_RULES.md +2 -0
  84. pal/input_savers/__init__.py +5 -0
  85. pal/input_savers/components/__init__.py +0 -0
  86. pal/input_savers/components/base.py +40 -0
  87. pal/input_savers/components/methods.py +41 -0
  88. pal/input_savers/components/verifier.py +39 -0
  89. pal/input_savers/pipeline.py +154 -0
  90. pal/input_savers/utils/__init__.py +0 -0
  91. pal/input_savers/utils/guards.py +94 -0
  92. pal/knowledge_graph/__init__.py +10 -0
  93. pal/knowledge_graph/components/__init__.py +0 -0
  94. pal/knowledge_graph/components/base.py +50 -0
  95. pal/knowledge_graph/components/builder.py +54 -0
  96. pal/knowledge_graph/components/retriever.py +100 -0
  97. pal/knowledge_graph/components/workspace.py +116 -0
  98. pal/knowledge_graph/pipeline.py +215 -0
  99. pal/knowledge_graph/utils/__init__.py +1 -0
  100. pal/latency_tracker/__init__.py +3 -0
  101. pal/latency_tracker/pipeline.py +86 -0
  102. pal/llm_scheduler/__init__.py +11 -0
  103. pal/llm_scheduler/api.py +137 -0
  104. pal/llm_scheduler/code.py +28 -0
  105. pal/llm_scheduler/core.py +92 -0
  106. pal/llm_scheduler/local.py +215 -0
  107. pal/llm_scheduler/pipeline.py +42 -0
  108. pal/llm_scheduler/types.py +92 -0
  109. pal/llm_wrapper/__init__.py +34 -0
  110. pal/llm_wrapper/adapter.py +316 -0
  111. pal/llm_wrapper/components/__init__.py +23 -0
  112. pal/llm_wrapper/components/adapter_types.py +90 -0
  113. pal/llm_wrapper/components/base.py +195 -0
  114. pal/llm_wrapper/components/features.py +104 -0
  115. pal/llm_wrapper/components/litellm_wrapper.py +215 -0
  116. pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
  117. pal/llm_wrapper/modes.py +73 -0
  118. pal/llm_wrapper/pipeline.py +61 -0
  119. pal/llm_wrapper/utils/__init__.py +4 -0
  120. pal/llm_wrapper/utils/registry.py +49 -0
  121. pal/llm_wrapper/utils/token_counting.py +10 -0
  122. pal/local_llm/__init__.py +5 -0
  123. pal/local_llm/components/__init__.py +0 -0
  124. pal/local_llm/components/huggingface_local.py +217 -0
  125. pal/local_llm/components/local_llm.py +60 -0
  126. pal/logging_setup.py +18 -0
  127. pal/memory/__init__.py +7 -0
  128. pal/memory/components/__init__.py +6 -0
  129. pal/memory/components/lru_k.py +56 -0
  130. pal/memory/components/manager.py +98 -0
  131. pal/memory/components/note.py +54 -0
  132. pal/memory/components/storage_manager.py +60 -0
  133. pal/memory/pipeline.py +122 -0
  134. pal/memory/utils/__init__.py +3 -0
  135. pal/memory/utils/keyword_overlap.py +3 -0
  136. pal/memory_tracker/__init__.py +3 -0
  137. pal/memory_tracker/components/__init__.py +0 -0
  138. pal/memory_tracker/components/process.py +56 -0
  139. pal/memory_tracker/pipeline.py +44 -0
  140. pal/metrics/__init__.py +4 -0
  141. pal/metrics/components/__init__.py +4 -0
  142. pal/metrics/components/collector.py +140 -0
  143. pal/metrics/components/exporter.py +28 -0
  144. pal/metrics/pipeline.py +49 -0
  145. pal/metrics/utils/__init__.py +2 -0
  146. pal/notes_manager/__init__.py +5 -0
  147. pal/notes_manager/components/__init__.py +0 -0
  148. pal/notes_manager/components/privacy.py +14 -0
  149. pal/notes_manager/components/recall.py +31 -0
  150. pal/notes_manager/components/tidy.py +28 -0
  151. pal/notes_manager/pipeline.py +138 -0
  152. pal/orchestrator/__init__.py +26 -0
  153. pal/orchestrator/components/__init__.py +26 -0
  154. pal/orchestrator/components/agent.py +105 -0
  155. pal/orchestrator/components/cgroup_manager.py +209 -0
  156. pal/orchestrator/components/context_interrupt.py +220 -0
  157. pal/orchestrator/components/limits.py +9 -0
  158. pal/orchestrator/components/scheduler.py +69 -0
  159. pal/orchestrator/components/syscall_scheduler.py +200 -0
  160. pal/orchestrator/pipeline.py +228 -0
  161. pal/orchestrator/utils/__init__.py +4 -0
  162. pal/orchestrator/utils/device.py +164 -0
  163. pal/orchestrator/utils/protocol.py +32 -0
  164. pal/os_kernel/__init__.py +5 -0
  165. pal/os_kernel/filesystem.py +80 -0
  166. pal/os_kernel/network.py +146 -0
  167. pal/os_kernel/pipeline.py +175 -0
  168. pal/os_kernel/sysinfo.py +36 -0
  169. pal/paths.py +59 -0
  170. pal/pipeline.py +177 -0
  171. pal/py.typed +0 -0
  172. pal/redact.py +19 -0
  173. pal/sdk/__init__.py +33 -0
  174. pal/sdk/adapters.py +43 -0
  175. pal/sdk/agent.py +99 -0
  176. pal/sdk/components/__init__.py +14 -0
  177. pal/sdk/components/autogen_adapter.py +65 -0
  178. pal/sdk/components/autogen_client.py +98 -0
  179. pal/sdk/components/framework_adapter_base.py +69 -0
  180. pal/sdk/components/metagpt_adapter.py +46 -0
  181. pal/sdk/components/open_interpreter_adapter.py +47 -0
  182. pal/sdk/components/tool_catalog.py +44 -0
  183. pal/sdk/os_api.py +84 -0
  184. pal/sdk/patterns.py +129 -0
  185. pal/sdk/pipeline.py +240 -0
  186. pal/sdk/queries.py +68 -0
  187. pal/sdk/savers.py +88 -0
  188. pal/sdk/utils/__init__.py +2 -0
  189. pal/storage_manager/__init__.py +17 -0
  190. pal/storage_manager/components/__init__.py +19 -0
  191. pal/storage_manager/components/base.py +50 -0
  192. pal/storage_manager/components/local_file_backend.py +84 -0
  193. pal/storage_manager/components/storage_queue.py +91 -0
  194. pal/storage_manager/components/vector_store_backend.py +122 -0
  195. pal/storage_manager/pipeline.py +153 -0
  196. pal/storage_manager/utils/__init__.py +4 -0
  197. pal/storage_manager/utils/memory_adapter.py +60 -0
  198. pal/storage_manager/utils/sdk.py +93 -0
  199. pal/studio/__init__.py +0 -0
  200. pal/studio/builder.py +166 -0
  201. pal/studio/catalog.py +90 -0
  202. pal/studio/checks.py +129 -0
  203. pal/studio/clients.py +6 -0
  204. pal/studio/editor.py +137 -0
  205. pal/studio/files.py +6 -0
  206. pal/studio/models.py +6 -0
  207. pal/studio/plan.py +149 -0
  208. pal/studio/planner.py +205 -0
  209. pal/studio/policy.py +69 -0
  210. pal/studio/routes.py +254 -0
  211. pal/studio/runs.py +210 -0
  212. pal/task_router/__init__.py +11 -0
  213. pal/task_router/components/__init__.py +0 -0
  214. pal/task_router/components/answerer.py +37 -0
  215. pal/task_router/components/base.py +34 -0
  216. pal/task_router/components/classifier.py +40 -0
  217. pal/task_router/components/gates.py +23 -0
  218. pal/task_router/pipeline.py +61 -0
  219. pal/task_router/utils/__init__.py +1 -0
  220. pal/terminal/__init__.py +1 -0
  221. pal/terminal/backend.py +141 -0
  222. pal/terminal/bench.py +173 -0
  223. pal/terminal/cli.py +826 -0
  224. pal/terminal/clients.py +170 -0
  225. pal/terminal/commands.py +401 -0
  226. pal/terminal/decide.py +242 -0
  227. pal/terminal/files.py +55 -0
  228. pal/terminal/flow_cli.py +439 -0
  229. pal/terminal/hardware.py +199 -0
  230. pal/terminal/history.py +88 -0
  231. pal/terminal/keys.py +83 -0
  232. pal/terminal/local_models.py +261 -0
  233. pal/terminal/logo.py +117 -0
  234. pal/terminal/models.py +99 -0
  235. pal/terminal/policy.py +54 -0
  236. pal/terminal/project.py +413 -0
  237. pal/terminal/review.py +65 -0
  238. pal/terminal/security.py +239 -0
  239. pal/terminal/session.py +503 -0
  240. pal/terminal/setup.py +354 -0
  241. pal/terminal/syntax.py +98 -0
  242. pal/terminal/theme.py +37 -0
  243. pal/terminal/ui.py +647 -0
  244. pal/terminal/voice.py +137 -0
  245. pal/terminal/workflow.py +624 -0
  246. pal/token_tracker/__init__.py +3 -0
  247. pal/token_tracker/pipeline.py +54 -0
  248. pal/tokens.py +21 -0
  249. pal/tool_catalog.py +107 -0
  250. pal/tool_manager/__init__.py +17 -0
  251. pal/tool_manager/builtin.py +82 -0
  252. pal/tool_manager/components/__init__.py +19 -0
  253. pal/tool_manager/components/base.py +53 -0
  254. pal/tool_manager/components/conflict_resolver.py +27 -0
  255. pal/tool_manager/components/param_validator.py +41 -0
  256. pal/tool_manager/components/tool_call_queue.py +111 -0
  257. pal/tool_manager/components/tool_registry.py +60 -0
  258. pal/tool_manager/pipeline.py +80 -0
  259. pal/tool_manager/utils/__init__.py +3 -0
  260. pal/web/__init__.py +3 -0
  261. pal/web/app.py +276 -0
  262. pal/web/static/editor.js +219 -0
  263. pal/web/static/fonts/LICENSE-Inter.txt +3 -0
  264. pal/web/static/fonts/inter-400.woff2 +0 -0
  265. pal/web/static/fonts/inter-500.woff2 +0 -0
  266. pal/web/static/fonts/inter-600.woff2 +0 -0
  267. pal/web/static/fonts/inter-700.woff2 +0 -0
  268. pal/web/static/inter.css +5 -0
  269. pal/web/static/pal-logo-256.png +0 -0
  270. pal/web/static/pal-logo-32.png +0 -0
  271. pal/web/static/pal-logo-64.png +0 -0
  272. pal/web/static/studio.css +306 -0
  273. pal/web/static/studio.js +533 -0
  274. pal/web/static/voice.js +76 -0
  275. pal/web/templates/index.html +368 -0
  276. pal/web/templates/studio.html +120 -0
  277. pal_code-0.1.0.dist-info/METADATA +379 -0
  278. pal_code-0.1.0.dist-info/RECORD +282 -0
  279. pal_code-0.1.0.dist-info/WHEEL +5 -0
  280. pal_code-0.1.0.dist-info/entry_points.txt +2 -0
  281. pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
  282. pal_code-0.1.0.dist-info/top_level.txt +1 -0
pal/terminal/decide.py ADDED
@@ -0,0 +1,242 @@
1
+ """Decisions: one question with a few choices, asked the way Claude asks, in Pal's colours.
2
+
3
+ ┌ Database ──────────────────────────────────────────────────────────────
4
+ │ Which database should the app use?
5
+ │
6
+ │ ❯ 1. SQLite (Recommended)
7
+ │ one file, no server: right for a small app
8
+ │ 2. PostgreSQL
9
+ │ a server; better for many users at once
10
+ │ 3. Something else… (type it)
11
+ └ ↑↓ choose · enter select · 1-9 jump · esc skip
12
+
13
+ Used when the Ask agent needs a choice from the person to answer well (Build and Plan never stop to ask). Without a terminal (pipes, tests) it falls back to a numbered list and a typed answer.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import sys
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass, field
22
+
23
+ from rich.console import Console
24
+ from rich.text import Text
25
+
26
+ from pal.terminal import theme
27
+
28
+ OTHER = "Something else…"
29
+ MAX_QUESTIONS = 3
30
+ MAX_OPTIONS = 5
31
+
32
+
33
+ @dataclass
34
+ class Option:
35
+ label: str
36
+ description: str = ""
37
+
38
+
39
+ @dataclass
40
+ class Question:
41
+ question: str
42
+ options: list[Option] = field(default_factory=list)
43
+ header: str = "Decision"
44
+ recommended: int | None = None
45
+ other: bool = True # a last "Something else…" choice that takes typed text
46
+
47
+ def labels(self) -> list[str]:
48
+ return [o.label + (" (Recommended)" if i == self.recommended else "") for i, o in enumerate(self.options)]
49
+
50
+
51
+ # -- what the agents send --------------------------------------------------------------------------------------------------------
52
+ DECIDE = ("Questions back (Ask agent only): when a good answer depends on a choice only the person can make (which of several meanings they intend, "
53
+ "their goal, a trade-off they must pick), do not guess: ask. End your reply with a line `### QUESTION` and a ```json block: "
54
+ "{\"header\": \"a 1-2 word topic\", \"question\": \"...?\", \"options\": [{\"label\": \"1-5 words\", \"description\": \"one line on what it "
55
+ "leads to\"}], \"recommended\": 0} with 2 to 4 options, the one you recommend first (or a JSON list of up to 3 such questions). "
56
+ "Ask only when it really matters; otherwise answer directly.")
57
+
58
+ _BLOCK = re.compile(r"^#+\s*QUESTIONS?\s*$\s*```(?:json)?\s*\n(.*?)```", re.M | re.S | re.I)
59
+
60
+
61
+ def parse_questions(text: str) -> tuple[list[Question], str]:
62
+ """The questions in a reply's `### QUESTION` block, and the reply without the block. A block that is not valid is left in the text."""
63
+ m = _BLOCK.search(text or "")
64
+ if not m:
65
+ return [], text
66
+ try:
67
+ raw = json.loads(m.group(1))
68
+ except ValueError:
69
+ return [], text
70
+ items = raw if isinstance(raw, list) else [raw]
71
+ out: list[Question] = []
72
+ for item in items[:MAX_QUESTIONS]:
73
+ if not isinstance(item, dict) or not str(item.get("question", "")).strip():
74
+ continue
75
+ opts = []
76
+ for o in item.get("options") or []:
77
+ if isinstance(o, str) and o.strip():
78
+ opts.append(Option(o.strip()[:80]))
79
+ elif isinstance(o, dict) and str(o.get("label", "")).strip():
80
+ opts.append(Option(str(o["label"]).strip()[:80], str(o.get("description", "")).strip()[:200]))
81
+ if len(opts) < 2:
82
+ continue
83
+ rec = item.get("recommended")
84
+ out.append(Question(str(item["question"]).strip()[:300], opts[:MAX_OPTIONS], str(item.get("header") or "Decision").strip()[:24],
85
+ rec if isinstance(rec, int) and 0 <= rec < len(opts[:MAX_OPTIONS]) else None))
86
+ if not out:
87
+ return [], text
88
+ return out, (text[:m.start()] + text[m.end():]).strip()
89
+
90
+
91
+ def answers_message(questions: list[Question], answers: list[str]) -> str:
92
+ """What goes back to the agent once the person has decided."""
93
+ lines = [f"- {q.question} → {a}" for q, a in zip(questions, answers)]
94
+ return "My decisions:\n" + "\n".join(lines) + "\nGo ahead with these."
95
+
96
+
97
+ # -- asking -------------------------------------------------------------------------------------------------------------------------
98
+ def _interactive() -> bool:
99
+ return sys.stdin.isatty() and sys.stdout.isatty()
100
+
101
+
102
+ def ask(console: Console, q: Question, reader: Callable[[str], str] | None = None) -> str | None:
103
+ """The chosen label (or the typed answer for "Something else…"), or None when the person skips (Esc, Ctrl+C)."""
104
+ if reader is not None or not _interactive():
105
+ return _ask_plain(console, q, reader or (lambda prompt: console.input(prompt)))
106
+ return _ask_card(q)
107
+
108
+
109
+ def _ask_plain(console: Console, q: Question, reader: Callable[[str], str]) -> str | None:
110
+ console.print(Text(f" {q.header}: {q.question}", style=f"bold {theme.WHITE}"))
111
+ labels = q.labels() + ([OTHER] if q.other else [])
112
+ for i, label in enumerate(labels, 1):
113
+ desc = q.options[i - 1].description if i <= len(q.options) else "type your own answer"
114
+ console.print(Text.assemble((f" {i}. ", theme.RED_HI), (label, theme.WHITE), (f" {desc}" if desc else "", theme.MUTED)))
115
+ try:
116
+ raw = reader(" choose a number (Enter: " + (str((q.recommended or 0) + 1)) + "): ").strip()
117
+ except (EOFError, KeyboardInterrupt):
118
+ return None
119
+ if not raw:
120
+ return q.options[q.recommended or 0].label
121
+ if raw.isdigit() and 1 <= int(raw) <= len(labels):
122
+ n = int(raw) - 1
123
+ if n < len(q.options):
124
+ return q.options[n].label
125
+ try:
126
+ typed = reader(" your answer: ").strip()
127
+ except (EOFError, KeyboardInterrupt):
128
+ return None
129
+ return typed or None
130
+ return raw # typed an answer directly
131
+
132
+
133
+ def _ask_card(q: Question) -> str | None:
134
+ from prompt_toolkit import Application
135
+ from prompt_toolkit.buffer import Buffer
136
+ from prompt_toolkit.filters import Condition
137
+ from prompt_toolkit.formatted_text import FormattedText
138
+ from prompt_toolkit.key_binding import KeyBindings
139
+ from prompt_toolkit.layout import HSplit, Layout, VSplit, Window
140
+ from prompt_toolkit.layout.containers import ConditionalContainer
141
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
142
+ from prompt_toolkit.styles import Style
143
+
144
+ labels = q.labels() + ([OTHER] if q.other else [])
145
+ n_other = len(q.options) if q.other else -1
146
+ sel = {"i": q.recommended or 0}
147
+ out: dict[str, str | None] = {"result": None}
148
+ typed = Buffer(multiline=False)
149
+ style = Style.from_dict({"bar": theme.RED, "head": f"bold {theme.RED_HI}", "q": f"bold {theme.WHITE}", "sel": f"bold {theme.WHITE}",
150
+ "pointer": f"bold {theme.RED}", "opt": theme.INK, "desc": theme.MUTED, "hint": theme.MUTED, "key": f"bold {theme.WHITE}"})
151
+
152
+ def body():
153
+ out = [("class:bar", " ┌ "), ("class:head", q.header), ("class:bar", " " + "─" * max(4, 60 - len(q.header)) + "\n"),
154
+ ("class:bar", " │ "), ("class:q", q.question + "\n"), ("class:bar", " │\n")]
155
+ for i, label in enumerate(labels):
156
+ on = i == sel["i"]
157
+ out += [("class:bar", " │ "), ("class:pointer", "❯ " if on else " "), ("class:sel" if on else "class:opt", f"{i + 1}. {label}")]
158
+ if i == n_other:
159
+ out.append(("class:desc", " (type it below)" if not on else " type, then enter"))
160
+ out.append(("", "\n"))
161
+ desc = q.options[i].description if i < len(q.options) else ""
162
+ if desc:
163
+ out += [("class:bar", " │ "), ("class:desc", f" {desc}\n")]
164
+ return FormattedText(out)
165
+
166
+ def hints():
167
+ return FormattedText([("class:bar", " └ "), ("class:key", "↑↓"), ("class:hint", " choose · "), ("class:key", "enter"), ("class:hint", " select · "),
168
+ ("class:key", "1-9"), ("class:hint", " jump · "), ("class:key", "esc"), ("class:hint", " skip")])
169
+ kb = KeyBindings()
170
+ typing = Condition(lambda: sel["i"] == n_other)
171
+
172
+ @kb.add("up")
173
+ def _(e):
174
+ sel["i"] = (sel["i"] - 1) % len(labels)
175
+
176
+ @kb.add("down")
177
+ @kb.add("tab")
178
+ def _(e):
179
+ sel["i"] = (sel["i"] + 1) % len(labels)
180
+
181
+ for d in range(1, min(9, len(labels)) + 1):
182
+ @kb.add(str(d), filter=~typing | Condition(lambda: not typed.text))
183
+ def _(e, d=d):
184
+ sel["i"] = d - 1
185
+ if d - 1 != n_other:
186
+ out["result"] = q.options[d - 1].label
187
+ e.app.exit()
188
+
189
+ @kb.add("enter")
190
+ def _(e):
191
+ if sel["i"] == n_other:
192
+ if typed.text.strip():
193
+ out["result"] = typed.text.strip()
194
+ e.app.exit()
195
+ return
196
+ out["result"] = q.options[sel["i"]].label
197
+ e.app.exit()
198
+
199
+ @kb.add("escape", eager=True)
200
+ @kb.add("c-c")
201
+ def _(e):
202
+ e.app.exit()
203
+ typed_win = Window(BufferControl(typed), height=1)
204
+ field_row = ConditionalContainer(VSplit([Window(FormattedTextControl([("class:bar", " │ "), ("class:pointer", "› ")]), width=9, dont_extend_width=True),
205
+ typed_win]), filter=typing)
206
+ if n_other >= 0: # typing anything goes to "Something else…"
207
+ typed.on_text_changed += lambda _b: sel.update(i=n_other) if typed.text else None
208
+ root = HSplit([Window(FormattedTextControl(body), dont_extend_height=True), field_row, Window(FormattedTextControl(hints), height=1)])
209
+ app: Application = Application(layout=Layout(root, focused_element=typed_win), key_bindings=kb, style=style, full_screen=False, erase_when_done=True)
210
+ try:
211
+ app.run()
212
+ except (EOFError, KeyboardInterrupt):
213
+ return None
214
+ return out["result"]
215
+
216
+
217
+ def show_answer(console: Console, q: Question, answer: str | None) -> None:
218
+ """What stays on screen after the card disappears: the question and what was chosen."""
219
+ console.print(Text.assemble((" ◆ ", theme.RED), (f"{q.question} ", theme.INK), ("→ " + answer if answer else "→ skipped", f"bold {theme.WHITE}" if answer else theme.MUTED)))
220
+
221
+
222
+ def ask_all(console: Console, questions: list[Question], reader: Callable[[str], str] | None = None) -> list[str] | None:
223
+ """Every question in turn. None when the person skips one."""
224
+ answers = []
225
+ for q in questions:
226
+ a = ask(console, q, reader)
227
+ show_answer(console, q, a)
228
+ if a is None:
229
+ return None
230
+ answers.append(a)
231
+ return answers
232
+
233
+
234
+ def yes_no(console: Console, question: str, yes: str = "Yes", no: str = "No", yes_about: str = "", no_about: str = "", default: bool = False,
235
+ header: str = "Decision", reader: Callable[[str], str] | None = None) -> bool:
236
+ """A yes/no decision as a card. The default is the recommended choice."""
237
+ q = Question(question, [Option(yes, yes_about), Option(no, no_about)], header, 0 if default else 1, other=False)
238
+ if reader is None and not _interactive():
239
+ return default
240
+ a = ask(console, q, reader)
241
+ show_answer(console, q, a)
242
+ return a == yes
pal/terminal/files.py ADDED
@@ -0,0 +1,55 @@
1
+ """Reading a model's reply: which files did it write? Tolerant of the many ways models label a code block."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+
7
+ _FENCE = re.compile(r"```(?P<info>[^\n`]*)\n(?P<body>.*?)\n?```", re.S)
8
+ _NAME = re.compile(r"(?<![\w.-])((?:[A-Za-z]:)?[/\\]?(?:(?:\.\.|[\w-]+)[/\\])*[\w-][\w.-]*\.[A-Za-z0-9]{1,5})(?![\w/\\-])")
9
+ _INFO = re.compile(r"(?:title|filename|file|path)\s*=\s*[\"']?([^\s\"']+)", re.I)
10
+ _FIRST_LINE = re.compile(r"^\s*(?:<!--|//|#|/\*)\s*(?:file(?:name)?|path)\s*:\s*(\S+?)\s*(?:-->|\*/)?\s*$", re.I)
11
+
12
+
13
+ @dataclass
14
+ class ParsedFile:
15
+ path: str
16
+ content: str
17
+ how: str = ""
18
+
19
+
20
+ def _hint(text_before: str) -> str:
21
+ """A file name in the last non-empty line before a code block, or ''."""
22
+ for line in reversed(text_before.splitlines()):
23
+ if line.strip():
24
+ names = _NAME.findall(line)
25
+ return names[-1] if names else ""
26
+ return ""
27
+
28
+
29
+ def parse_files(reply: str, default_name: str = "") -> tuple[list[ParsedFile], int]:
30
+ """(the files in the reply, how many code blocks had no usable name). The name comes from the line before the block ("### FILE: index.html",
31
+ "**style.css**", "`app.py`"), from the block's info string (title="x"), or from a first-line comment (<!-- file: x -->). A reply with exactly one
32
+ unnamed block uses `default_name`, if there is one."""
33
+ found: list[ParsedFile] = []
34
+ unnamed = 0
35
+ blocks = list(_FENCE.finditer(reply))
36
+ for i, m in enumerate(blocks):
37
+ before = reply[(blocks[i - 1].end() if i else 0):m.start()]
38
+ body = m.group("body")
39
+ name, how = "", ""
40
+ info = _INFO.search(m.group("info"))
41
+ if info:
42
+ name, how = info.group(1), "info string"
43
+ if not name:
44
+ first = _FIRST_LINE.match(body.split("\n", 1)[0])
45
+ if first:
46
+ name, how, body = first.group(1), "first-line comment", body.split("\n", 1)[1] if "\n" in body else ""
47
+ if not name:
48
+ name, how = _hint(before), "line before"
49
+ if not name and len(blocks) == 1 and default_name:
50
+ name, how = default_name, "default"
51
+ if not name:
52
+ unnamed += 1
53
+ continue
54
+ found.append(ParsedFile(name.strip("`*"), body.rstrip("\n") + "\n", how))
55
+ return found, unnamed