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/ui.py ADDED
@@ -0,0 +1,647 @@
1
+ """The Pal screen: a centred column in the terminal, a prompt box with a red bar, and a status line under it.
2
+
3
+ The box is a small prompt_toolkit application that disappears when you press Enter; what you said and what Pal answered are then printed with rich,
4
+ so they stay in the terminal's own scroll-back and can be selected and copied. Colours are black, red and white.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shutil
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+
13
+ from prompt_toolkit import Application
14
+ from prompt_toolkit.buffer import Buffer
15
+ from prompt_toolkit.filters import Condition
16
+ from prompt_toolkit.formatted_text import FormattedText
17
+ from prompt_toolkit.key_binding import KeyBindings
18
+ from prompt_toolkit.layout import Dimension, HSplit, Layout, VSplit, Window, WindowAlign
19
+ from prompt_toolkit.layout.containers import ConditionalContainer
20
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
21
+ from prompt_toolkit.layout.processors import AfterInput, ConditionalProcessor
22
+ from prompt_toolkit.styles import Style
23
+ from rich.console import Console, Group, RenderableType
24
+ from rich.markdown import Markdown
25
+ from rich.padding import Padding
26
+ from rich.table import Table
27
+ from rich.text import Text
28
+ from rich.theme import Theme
29
+
30
+ from pal.terminal import theme
31
+ from pal.terminal.logo import BRAILLE, braille_lines, logo_lines, ptk_style
32
+ from pal.terminal.security import SEVERITIES, Finding, clean_terminal
33
+ from pal.terminal.session import Change, PalSession, Turn
34
+
35
+ COMMANDS: list[tuple[str, str]] = [
36
+ ("/help", "show the commands"), ("/mode", "switch agent: build, plan or ask (or press tab)"), ("/model", "show or switch the model"),
37
+ ("/files", "list the files Pal can see in this folder"), ("/security", "check this folder for security problems (add 'ai' for a model review)"),
38
+ ("/budget", "cap the tokens this session may use: /budget 50000, /budget off"), ("/key", "use an API key for this session: /key openrouter sk-..."),
39
+ ("/add", "attach a file from anywhere (PDF, Word, code ...); dropping a file on the window works too"), ("/tokens", "tokens saved, and where (last, turns, help)"),
40
+ ("/project", "this folder's setup: project map, code graph, documents as markdown, local model (/project rebuild)"),
41
+ ("/exit", "leave Pal"),
42
+ ]
43
+ PLACEHOLDER = 'Ask anything\u2026 "Fix a TODO in the codebase" (@file adds a file, \\+Enter a new line)'
44
+ RICH_THEME = Theme({"markdown.code": f"bold {theme.WHITE} on {theme.PANEL}", "markdown.code_block": f"{theme.INK} on {theme.PANEL}", "markdown.h1": f"bold {theme.RED_HI}",
45
+ "markdown.h2": f"bold {theme.WHITE}", "markdown.h3": f"bold {theme.WHITE}", "markdown.link": f"underline {theme.WHITE}", "markdown.item.bullet": theme.RED, "markdown.item.number": theme.RED_HI})
46
+ STYLE = Style.from_dict({
47
+ "screen": f"bg:{theme.BG}", "placeholder": theme.MUTED, "prompt": f"{theme.RED} bold", "frame.border": "#8C8C8C",
48
+ "mode.build": f"{theme.RED_HI} bold", "mode.plan": f"{theme.WHITE} bold", "mode.ask": "#D9D9D9 bold", "model": theme.WHITE, "muted": theme.MUTED, "hint": theme.MUTED,
49
+ "hintkey": f"{theme.WHITE} bold", "ok": f"{theme.RED_HI} bold", "cmd": f"{theme.WHITE} bold", "cmddesc": theme.MUTED, "warn": f"{theme.RED_HI}",
50
+ })
51
+
52
+
53
+ def metrics(cols: int | None = None) -> tuple[int, int]:
54
+ """(width of the conversation column, spaces on its left): the full width of the terminal, inside a small margin, like the input box."""
55
+ cols = cols or shutil.get_terminal_size((100, 30)).columns
56
+ return max(40, min(cols - 6, 140)), 3
57
+
58
+
59
+ def make_console() -> Console:
60
+ return Console(theme=RICH_THEME, highlight=False, soft_wrap=False)
61
+
62
+
63
+ def short_path(p: Path) -> str:
64
+ home = Path.home()
65
+ try:
66
+ return "~" + os.sep + str(p.relative_to(home)) if p != home else "~"
67
+ except ValueError:
68
+ return str(p)
69
+
70
+
71
+ def say(console: Console, renderable: RenderableType) -> None:
72
+ """Print inside the centred column."""
73
+ width, pad = metrics(console.width)
74
+ console.print(Padding(renderable, (0, 0, 0, pad)), width=pad + width)
75
+
76
+
77
+ def show_user(console: Console, text: str) -> None:
78
+ out = Text()
79
+ lines = text.split("\n")
80
+ for i, line in enumerate(lines):
81
+ out.append("▌ ", style=theme.RED)
82
+ out.append(line, style=f"bold {theme.WHITE}")
83
+ out.append("\n" if i < len(lines) - 1 else "")
84
+ say(console, out)
85
+ console.print()
86
+
87
+
88
+ def show_reply(console: Console, turn: Turn, session: PalSession) -> None:
89
+ for w in turn.warnings:
90
+ say(console, Text(clean_terminal(w), style=theme.MUTED))
91
+ if turn.text:
92
+ grid = Table.grid(padding=0)
93
+ grid.add_column(width=2)
94
+ grid.add_column(ratio=1)
95
+ grid.add_row(Text("✻", style=theme.RED), Markdown(clean_terminal(turn.text), code_theme="ansi_dark"))
96
+ say(console, grid)
97
+ for path, why in turn.refused:
98
+ say(console, Text(f" refused {path}: {why}", style=theme.RED_HI))
99
+ used = turn.prompt_tokens + turn.completion_tokens
100
+ saved = turn.plain_estimate - used
101
+ line = Text(" \u21B3 ", style=theme.MUTED)
102
+ line.append(f"{used:,} used" + (f" (+{turn.helper_tokens:,} helper)" if turn.helper_tokens else ""), style=theme.MUTED)
103
+ line.append(" \u00B7 ", style=theme.MUTED)
104
+ if saved > 0:
105
+ line.append(f"saved ~{saved:,}", style=f"bold {theme.RED_HI}")
106
+ else:
107
+ line.append("no saving here", style=theme.MUTED)
108
+ if session.saved > 0 and session.turns > 1:
109
+ line.append(f" \u00B7 total ~{session.saved:,} ({session.saved_pct:g}%)", style=theme.WHITE)
110
+ line.append(f" \u00B7 {turn.seconds:.1f}s" + (" \u00B7 /tokens" if session.turns <= 2 else ""), style=theme.MUTED)
111
+ say(console, line)
112
+ console.print()
113
+
114
+
115
+ def show_changes(console: Console, changes: list[Change], limit: int = 60) -> None:
116
+ for c in changes:
117
+ say(console, Text.assemble((" ", ""), ("+ " if c.is_new else "~ ", theme.RED), (c.path, f"bold {theme.WHITE}"), (" (new file)" if c.is_new else "", theme.MUTED)))
118
+ shown = c.diff[:limit]
119
+ body = Text()
120
+ for tag, raw in shown:
121
+ text = clean_terminal(raw)
122
+ if tag == "add":
123
+ body.append(f" + {text}\n", style=f"{theme.WHITE}")
124
+ elif tag == "del":
125
+ body.append(f" - {text}\n", style=f"{theme.RED_HI}")
126
+ elif tag == "hunk":
127
+ body.append(f" {text}\n", style=theme.MUTED)
128
+ else:
129
+ body.append(f" {text}\n", style=theme.MUTED)
130
+ if len(c.diff) > limit:
131
+ body.append(f" … {len(c.diff) - limit} more lines\n", style=theme.MUTED)
132
+ say(console, body)
133
+ found = [f for c in changes for f in c.findings]
134
+ if found:
135
+ say(console, Text(" Security check on these changes", style=f"bold {theme.WHITE}"))
136
+ show_findings(console, found, indent=4)
137
+ else:
138
+ say(console, Text(" Security check: nothing found in these changes (pattern checks, not a proof)", style=theme.MUTED))
139
+
140
+
141
+ SEV_STYLE = {"critical": f"bold {theme.WHITE} on {theme.RED}", "high": f"bold {theme.RED_HI}", "medium": theme.WHITE, "low": theme.MUTED}
142
+
143
+
144
+ def _flow_commands() -> list[tuple[str, str]]:
145
+ from pal.terminal.flow_cli import FLOW_COMMANDS
146
+ return FLOW_COMMANDS
147
+
148
+
149
+ def show_findings(console: Console, findings: list[Finding], indent: int = 0, limit: int = 40) -> None:
150
+ pad = " " * indent
151
+ for f in sorted(findings, key=lambda x: SEVERITIES.index(x.severity))[:limit]:
152
+ where = f"{f.path}:{f.line}" if f.line else f.path
153
+ say(console, Text.assemble((pad, ""), (f" {f.severity.upper()} ", SEV_STYLE[f.severity]), (" ", ""), (f.title, f"bold {theme.WHITE}"), (f" {where}", theme.MUTED)))
154
+ say(console, Padding(Text(f.why, style=theme.INK), (0, 0, 0, indent + 11)))
155
+ if len(findings) > limit:
156
+ say(console, Text(f"{pad}… and {len(findings) - limit} more", style=theme.MUTED))
157
+
158
+
159
+ def confirm(console: Console, question: str) -> bool:
160
+ try:
161
+ answer = console.input(Text.assemble((" " * (metrics(console.width)[1] + 2) + question + " ", theme.WHITE), ("[y/N] ", theme.MUTED))).strip().lower()
162
+ except (EOFError, KeyboardInterrupt):
163
+ console.print()
164
+ return False
165
+ return answer in ("y", "yes")
166
+
167
+
168
+ def show_help(console: Console) -> None:
169
+ grid = Table.grid(padding=(0, 2))
170
+ for name, desc in all_commands():
171
+ grid.add_row(Text(name, style=f"bold {theme.WHITE}"), Text(desc, style=theme.MUTED))
172
+ say(console, Group(grid, Text(""), Text("tab switches agent (Build / Plan / Ask) · @file adds a file · \\ then Enter (or Alt+Enter) for a new line · Ctrl+D exits", style=theme.MUTED)))
173
+ console.print()
174
+
175
+
176
+ LABELS = {
177
+ "input": "Trimmed filler from your message",
178
+ "history": "Left out old turns this request did not need",
179
+ "compression": "Shortened old replies (names, numbers and code signatures kept)",
180
+ "documents": "Sent only the relevant parts of indexed files",
181
+ "code": "Sent only the relevant code from the project map",
182
+ "files": "Did not resend @files the model already had",
183
+ "carried": "Kept earlier @files out of the conversation history",
184
+ }
185
+ SHORT = {"input": "message trimmed", "history": "old turns left out", "compression": "old replies shortened", "documents": "only relevant file parts",
186
+ "code": "only relevant code", "files": "@files not resent", "carried": "@files kept out of history"}
187
+ HELP_TOKENS = """How Pal counts tokens
188
+ used what the main model really read and wrote (its own counts when it reports them, else characters / 4)
189
+ helper Pal's small local jobs (trimming, checking): an extra cost, shown on its own
190
+ plain an estimate of what a plain chat would have used for the same request: it resends the whole
191
+ conversation and pastes every @file again each time
192
+ saved plain minus used. Every saving here is an estimate, not a measurement.
193
+
194
+ Where savings come from
195
+ """ + "\n".join(f" {v}" for v in LABELS.values()) + """
196
+ Other the part of the gap no stage reports by itself
197
+ Pal's own additions (negative) the answer rules and notices Pal adds to each request: why tiny chats can cost more
198
+
199
+ Commands: /tokens /tokens last /tokens turns /tokens help"""
200
+
201
+
202
+ def _bars(console: Console, saved_by: dict, gap: int) -> None:
203
+ """One row per stage, with a bar scaled to the largest saving, plus what no stage explains."""
204
+ rows = sorted(((LABELS.get(k, k), v) for k, v in saved_by.items() if v > 0), key=lambda r: -r[1])
205
+ rest = gap - sum(v for _, v in rows)
206
+ if rest > 0:
207
+ rows.append(("Other (not itemized)", rest))
208
+ top = max([v for _, v in rows] + [1])
209
+ grid = Table.grid(padding=(0, 2))
210
+ grid.add_column(width=24, no_wrap=True)
211
+ grid.add_column(justify="right", no_wrap=True)
212
+ grid.add_column(ratio=1)
213
+ for label, v in rows:
214
+ grid.add_row(Text("\u2588" * max(1, round(22 * v / top)), style=theme.RED), Text(f"{v:,}", style=f"bold {theme.WHITE}"), Text(label, style=theme.INK))
215
+ if rest < 0:
216
+ grid.add_row(Text(""), Text(f"{rest:,}", style=theme.MUTED), Text("Pal's own additions (answer rules and notices)", style=theme.MUTED))
217
+ if not rows and rest >= 0:
218
+ grid.add_row(Text(""), Text("0", style=theme.MUTED), Text("nothing saved yet", style=theme.MUTED))
219
+ say(console, grid)
220
+
221
+
222
+ def _head(used: int, helper: int, plain: int) -> Table:
223
+ saved = plain - used
224
+ pct = round(100.0 * saved / plain, 1) if plain else 0.0
225
+ grid = Table.grid(padding=(0, 3))
226
+ for k, v, style in (("used", f"{used:,}" + (f" (+{helper:,} helper)" if helper else ""), theme.WHITE), ("plain chat (est.)", f"{plain:,}", theme.INK),
227
+ ("saved", f"{saved:,} ({pct:g}%)" if saved > 0 else f"{saved:,} (Pal used more here)", f"bold {theme.RED_HI}" if saved > 0 else theme.MUTED)):
228
+ grid.add_row(Text(k, style=theme.MUTED), Text(v, style=style))
229
+ return grid
230
+
231
+
232
+ def show_tokens(console: Console, session: PalSession, arg: str = "") -> None:
233
+ """/tokens: the session; /tokens last: the last request; /tokens turns: every request; /tokens help: how it is counted."""
234
+ arg = arg.strip().lower()
235
+ if arg == "help":
236
+ say(console, Text(HELP_TOKENS, style=theme.INK))
237
+ elif not session.records:
238
+ say(console, Text("No requests yet. Ask Pal something, then type /tokens.", style=theme.MUTED))
239
+ elif arg == "last":
240
+ r = session.records[-1]
241
+ say(console, Text.assemble(("Last request ", f"bold {theme.WHITE}"), (r.text[:70] + ("\u2026" if len(r.text) > 70 else ""), theme.MUTED)))
242
+ say(console, _head(r.used, r.helper, r.plain))
243
+ console.print()
244
+ _bars(console, r.saved_by, r.saved)
245
+ elif arg == "turns":
246
+ grid = Table.grid(padding=(0, 2))
247
+ for width, just in ((3, "right"), (26, "left"), (8, "right"), (8, "right"), (8, "right"), (24, "left")):
248
+ grid.add_column(width=width, justify=just, no_wrap=True, overflow="ellipsis")
249
+ grid.add_row(*(Text(h, style=theme.MUTED) for h in ("#", "request", "used", "plain", "saved", "biggest saving")))
250
+ for i, r in enumerate(session.records, 1):
251
+ best = max(r.saved_by.items(), key=lambda kv: kv[1])[0] if r.saved_by else ""
252
+ grid.add_row(Text(str(i), style=theme.MUTED), Text(r.text.replace("\n", " "), style=theme.INK), Text(f"{r.used:,}", style=theme.WHITE),
253
+ Text(f"{r.plain:,}", style=theme.INK), Text(f"{r.saved:,}", style=f"bold {theme.RED_HI}" if r.saved > 0 else theme.MUTED),
254
+ Text(SHORT.get(best, ""), style=theme.MUTED))
255
+ say(console, grid)
256
+ else:
257
+ say(console, Text.assemble(("This session ", f"bold {theme.WHITE}"), (f"{session.turns} request{'s' if session.turns != 1 else ''}, {session.seconds:.1f}s", theme.MUTED)))
258
+ say(console, _head(session.pal_tokens, session.helper_tokens, session.plain_tokens))
259
+ console.print()
260
+ _bars(console, session.saved_by_total(), session.saved)
261
+ console.print()
262
+ say(console, Text("/tokens last the last request \u00B7 /tokens turns every request \u00B7 /tokens help how it is counted", style=theme.MUTED))
263
+ console.print()
264
+
265
+
266
+ def show_models(console: Console, session: PalSession, info: dict, hw, keyed: list[str]) -> None:
267
+ """/model: the model in use, the installed local models with how each fits this computer, what setup recommends, and API providers with a key."""
268
+ from pal.terminal.local_models import FIT_LABEL, installed_fit, recommend, tier_name
269
+ say(console, Text.assemble(("Now ", theme.MUTED), (model_label(session), f"bold {theme.WHITE}"),
270
+ (f" helper: {session.helper_model}" if session.helper_model else
271
+ " no local helper (no GPU: API only)" if not session.local else " helper: the main model (run pal setup for a small local helper)", theme.MUTED)))
272
+ say(console, Text(f"This computer: {hw.summary()} ({tier_name(hw)})", style=theme.MUTED))
273
+ console.print()
274
+ rows = installed_fit(info.get("models", []), hw) if info.get("ok") else []
275
+ if rows:
276
+ grid = Table.grid(padding=(0, 2))
277
+ for r in sorted(rows, key=lambda r: -r["rank"]):
278
+ grid.add_row(Text(r["name"], style=f"bold {theme.WHITE}" if r["name"] == session.model else theme.INK), Text(f"{r['need_gb']:g} GB", style=theme.MUTED),
279
+ Text(FIT_LABEL[r["fit"]], style=f"bold {theme.RED_HI}" if r["fit"] == "gpu" else theme.MUTED))
280
+ say(console, Text("Installed", style=f"bold {theme.WHITE}"))
281
+ say(console, Padding(grid, (0, 0, 0, 2)))
282
+ else:
283
+ say(console, Text("No local models found (is Ollama running?).", style=theme.MUTED))
284
+ rec = recommend(hw)
285
+ names = {r["name"] for r in rows}
286
+ if rec is None:
287
+ say(console, Text("This computer has no GPU, so Pal uses API models only.", style=theme.INK))
288
+ elif rec.main.tag not in names:
289
+ say(console, Text(f"Best for this computer: {rec.main.tag} ({rec.main.size_gb:g} GB). Get it with ollama pull {rec.main.tag} or pal setup", style=theme.INK))
290
+ say(console, Text(f"API providers with a key: {', '.join(keyed) or 'none (add one with /key <provider> <key>)'}", style=theme.MUTED))
291
+ say(console, Text("Switch with /model <name> or /model openrouter:<name> \u00B7 every local model by GPU: pal setup --list", style=theme.MUTED))
292
+ console.print()
293
+
294
+
295
+ def all_commands() -> list[tuple[str, str]]:
296
+ from pal.terminal.commands import COMMANDS as MORE
297
+ seen: set[str] = set()
298
+ out = []
299
+ for name, desc in COMMANDS[:-1] + MORE + _flow_commands() + COMMANDS[-1:]:
300
+ if name not in seen:
301
+ seen.add(name)
302
+ out.append((name, desc))
303
+ return out
304
+
305
+
306
+ def matching(text: str) -> list[tuple[str, str]]:
307
+ return [(n, d) for n, d in all_commands() if text.startswith("/") and " " not in text and n.startswith(text)][:10]
308
+
309
+
310
+ def model_label(session: PalSession) -> str:
311
+ return session.model + (" local" if session.provider == "local" else f" {session.provider}")
312
+
313
+
314
+ def brand_block() -> list[tuple[str, str]]:
315
+ """The logo with the wordmark beside it, centred on the logo's height (kept for wide screens and the tests)."""
316
+ art, word = logo_lines(), theme.wordmark()
317
+ top = (len(art) - len(word)) // 2
318
+ width = sum(len(c) for c, _ in word[0])
319
+ frags: list[tuple[str, str]] = []
320
+ for i, line in enumerate(art):
321
+ frags += [(ptk_style(st), ch) for ch, st in line]
322
+ frags.append(("", " "))
323
+ w = word[i - top] if 0 <= i - top < len(word) else [(" " * width, "")]
324
+ frags += [(f"fg:{colour}" if colour else "", chars) for chars, colour in w]
325
+ if i < len(art) - 1:
326
+ frags.append(("", "\n"))
327
+ return frags
328
+
329
+
330
+ def dot_logo() -> list[tuple[str, str]]:
331
+ """The logo in braille dots, as prompt_toolkit fragments: the start screen's dot-matrix mark."""
332
+ frags: list[tuple[str, str]] = []
333
+ lines = braille_lines()
334
+ for i, runs in enumerate(lines):
335
+ frags += [(f"fg:{colour}" if colour else "", chars) for chars, colour in runs]
336
+ if i < len(lines) - 1:
337
+ frags.append(("", "\n"))
338
+ return frags
339
+
340
+
341
+ def agent_line(session: PalSession) -> list[tuple[str, str]]:
342
+ helper = session.helper_model
343
+ provider, model = session.model_for() if hasattr(session, "model_for") else (session.provider, session.model)
344
+ return [("class:mode." + session.mode, f"{session.mode.capitalize()} agent"), ("class:hint", " \u00B7 "), ("class:model", model),
345
+ ("class:hint", " (local)" if provider == "local" else f" ({provider})"),
346
+ ("class:hint", f" \u00B7 helper {helper}" if helper else ""),
347
+ ("class:hint", " \u00B7 no GPU: API only" if not getattr(session, "local", True) else ""),
348
+ ("class:ok", f" \u00B7 {note()}" if (note := getattr(session, "status_note", None)) else "")]
349
+
350
+
351
+ def _k(n: int) -> str:
352
+ return f"{n / 1000:.1f}k" if n >= 1000 else str(n)
353
+
354
+
355
+ def project_line(session: PalSession) -> list[tuple[str, str]]:
356
+ """One line about the prepared folder, for the first screen: files, the map, documents, the graph, the local model."""
357
+ project = getattr(session, "project", None)
358
+ if project is None:
359
+ return []
360
+ r = project.report
361
+ bits = [f"{r.code_files} files"]
362
+ if r.map_tokens:
363
+ bits.append(f"map {_k(r.map_tokens)} tokens")
364
+ if r.converted:
365
+ bits.append(f"{len(r.converted)} doc{'s' if len(r.converted) != 1 else ''} → markdown")
366
+ graph = r.graph.replace("ready: ", "graph ") if r.graph.startswith("ready") else ("graph building…" if r.graph == "building" else "")
367
+ if graph:
368
+ bits.append(graph)
369
+ local = getattr(session, "local_status", "")
370
+ out = [("class:hint", "this folder: "), ("class:ok", " · ".join(bits))]
371
+ if local:
372
+ out += [("class:hint", " · "), ("class:hint", local)]
373
+ return out
374
+
375
+
376
+ def show_project(console: Console, session: PalSession) -> None:
377
+ """/project, and the summary printed while the folder is set up."""
378
+ project = getattr(session, "project", None)
379
+ if project is None:
380
+ say(console, Text("This session has no prepared folder.", style=theme.MUTED))
381
+ return
382
+ r = project.report
383
+
384
+ def row(ok: bool | None, name: str, text: str, note: str = "") -> None:
385
+ mark = ("✓", theme.RED_HI) if ok else ("…", theme.MUTED) if ok is None else ("✗", theme.MUTED)
386
+ say(console, Text.assemble((f"{mark[0]} ", mark[1]), (f"{name:<13}", f"bold {theme.WHITE}"), (text, theme.INK), (f" {note}" if note else "", theme.MUTED)))
387
+ local = getattr(session, "local_status", "")
388
+ if local:
389
+ row(not local.startswith(("Ollama is not", "Ollama did not", "no GPU")) or None, "Local LLM", local)
390
+ row(bool(r.code_files), "Project map", f"{r.code_files} files, {_k(r.map_tokens)} tokens" if r.code_files else "no source files here",
391
+ f"(the code itself is about {_k(r.source_tokens)})" if r.source_tokens else "")
392
+ g = r.graph
393
+ row(True if g.startswith("ready") else None if g == "building" else False, "Code graph",
394
+ g.replace("ready: ", "") + (" (graphify, no model involved)" if g.startswith("ready") else ""),
395
+ "built in the background; the session can start now" if g == "building" else "pip install graphifyy" if g == "needs graphify" else "")
396
+ conv = r.converted
397
+ text_docs = [d for d in r.docs if not d.converted and not d.error]
398
+ failed = [d for d in r.docs if d.error]
399
+ if r.docs:
400
+ row(True, "Documents", (f"{len(conv)} converted to markdown" if conv else "") + (", " if conv and text_docs else "")
401
+ + (f"{len(text_docs)} text file{'s' if len(text_docs) != 1 else ''}" if text_docs else ""), "indexed: only matching passages are sent")
402
+ for d in conv[:6]:
403
+ say(console, Text(f" {d.path} → {short_path(Path(d.markdown))}", style=theme.MUTED))
404
+ for d in failed[:4]:
405
+ say(console, Text(f" {d.path}: {d.error}", style=theme.MUTED))
406
+ else:
407
+ row(False, "Documents", "none in this folder", "PDF, Word, PowerPoint and Excel files are converted to markdown")
408
+ if r.tests:
409
+ row(True, "Tests", r.tests)
410
+
411
+
412
+ def read_input(session: PalSession, console: Console, save_mode: Callable[..., None] | None = None, landing: bool = False, version: str = "",
413
+ history=None, initial: str = "", on_shift_tab: Callable[[], str] | None = None) -> str | None:
414
+ """Show the input box and return what was typed, or None when the person leaves (Ctrl+Q, or Ctrl+D on an empty box).
415
+
416
+ `landing` draws the whole first screen full-screen: the folder at the top left, the dot-matrix logo, what Pal is set to, a full-width input
417
+ box and the key hints. Otherwise a status line, the box and the hints appear under the conversation and disappear on Enter."""
418
+ from prompt_toolkit.application import get_app
419
+ from prompt_toolkit.widgets import Frame
420
+ buf = Buffer(multiline=True)
421
+ if initial:
422
+ buf.text = initial
423
+ buf.cursor_position = len(initial)
424
+ empty = Condition(lambda: not buf.text)
425
+ result: dict[str, str | None] = {"text": None}
426
+ from pal.terminal.history import Browser
427
+ browse = Browser(history) if history is not None else None
428
+ ui_state: dict = {"sel": 0, "search": None, "flash": "", "recalled": False, "setting": False} # menu, Ctrl+R, a note, text from history
429
+ menu_open = Condition(lambda: bool(matching(buf.text)) and not ui_state["recalled"])
430
+
431
+ def size():
432
+ try:
433
+ return get_app().output.get_size()
434
+ except Exception: # noqa: BLE001 (no app yet)
435
+ return None
436
+
437
+ def status_left():
438
+ files = len(session.pending)
439
+ if ui_state["search"] is not None:
440
+ return FormattedText([("class:ok", "reverse search: "), ("class:model", ui_state["search"] or "…"), ("class:hint", " ctrl+r older · enter use · esc esc clear")])
441
+ if ui_state["flash"]:
442
+ return FormattedText([("class:ok", ui_state["flash"])])
443
+ return FormattedText(agent_line(session) + ([("class:ok", f" {files} file{'s' if files != 1 else ''} attached")] if files else []))
444
+
445
+ def status_right():
446
+ if not session.turns:
447
+ return FormattedText([("class:hint", "fewer tokens on every request")])
448
+ if session.saved > 0:
449
+ return FormattedText([("class:ok", f"saved {session.saved:,} tok"), ("class:hint", f" \u00B7 {session.saved_pct:g}% \u00B7 {session.pal_tokens:,} used")])
450
+ return FormattedText([("class:model", f"{session.pal_tokens:,} tok used")])
451
+
452
+ def menu():
453
+ rows: list[tuple[str, str]] = []
454
+ for i, (n, d) in enumerate(matching(buf.text)):
455
+ on = i == min(ui_state["sel"], len(matching(buf.text)) - 1)
456
+ rows += [("class:ok" if on else "class:cmd", f"{'❯ ' if on else ' '}{n:<14}"), ("class:cmddesc", d + "\n")]
457
+ if rows:
458
+ rows[-1] = (rows[-1][0], rows[-1][1].rstrip("\n"))
459
+ return FormattedText(rows)
460
+
461
+ def keys():
462
+ out: list[tuple[str, str]] = []
463
+ for k, label in (("enter", "send"), ("tab", "agent"), ("↑↓", "history"), ("/", "commands"), ("esc esc", "clear"), ("?", "shortcuts")):
464
+ out += [("class:hintkey", k), ("class:hint", f" {label} ")]
465
+ out[-1] = (out[-1][0], out[-1][1].rstrip())
466
+ return FormattedText(out)
467
+
468
+ kb = KeyBindings()
469
+
470
+ def pick() -> str | None:
471
+ m = matching(buf.text)
472
+ return m[min(ui_state["sel"], len(m) - 1)][0] if m else None
473
+
474
+ @kb.add("enter")
475
+ def _(event):
476
+ ui_state["search"] = None
477
+ chosen = pick() if not ui_state["recalled"] else None
478
+ if chosen and buf.text != chosen and buf.text.strip() != chosen: # Enter on the command menu takes the highlighted command
479
+ buf.text = chosen + " "
480
+ buf.cursor_position = len(buf.text)
481
+ ui_state["sel"] = 0
482
+ return
483
+ if buf.text.endswith("\\"):
484
+ buf.delete_before_cursor(1)
485
+ buf.insert_text("\n")
486
+ return
487
+ if buf.text.strip():
488
+ result["text"] = buf.text
489
+ event.app.exit()
490
+
491
+ @kb.add("escape", "enter")
492
+ @kb.add("c-j")
493
+ def _(event):
494
+ buf.insert_text("\n")
495
+
496
+ @kb.add("tab")
497
+ def _(event):
498
+ chosen = pick()
499
+ if chosen:
500
+ buf.text = chosen + " "
501
+ buf.cursor_position = len(buf.text)
502
+ ui_state["sel"] = 0
503
+ else:
504
+ session.toggle_mode()
505
+ if save_mode:
506
+ save_mode(mode=session.mode)
507
+
508
+ @kb.add("up", filter=menu_open)
509
+ def _(event):
510
+ ui_state["sel"] = max(0, ui_state["sel"] - 1)
511
+
512
+ @kb.add("down", filter=menu_open)
513
+ def _(event):
514
+ ui_state["sel"] = min(len(matching(buf.text)) - 1, ui_state["sel"] + 1)
515
+
516
+ def set_text(text: str) -> None:
517
+ ui_state["setting"] = True
518
+ buf.text = text
519
+ buf.cursor_position = len(text)
520
+ ui_state.update(setting=False, recalled=True) # a recalled line does not open the command menu
521
+
522
+ @kb.add("up", filter=~menu_open)
523
+ def _(event):
524
+ if buf.document.cursor_position_row > 0 or browse is None: # inside a multi-line message: move up a line
525
+ buf.cursor_up()
526
+ return
527
+ older = browse.older(buf.text)
528
+ if older is not None:
529
+ set_text(older)
530
+
531
+ @kb.add("down", filter=~menu_open)
532
+ def _(event):
533
+ if buf.document.cursor_position_row < buf.document.line_count - 1 or browse is None:
534
+ buf.cursor_down()
535
+ return
536
+ newer = browse.newer()
537
+ if newer is not None:
538
+ set_text(newer)
539
+
540
+ @kb.add("escape", "escape")
541
+ def _(event):
542
+ buf.reset()
543
+ ui_state.update(search=None, sel=0)
544
+
545
+ @kb.add("c-r")
546
+ def _(event):
547
+ if browse is None:
548
+ return
549
+ if ui_state["search"] is None:
550
+ ui_state["search"] = buf.text
551
+ browse.pos = len(browse.h.items)
552
+ found = browse.find(ui_state["search"])
553
+ if found is not None:
554
+ set_text(found)
555
+ else:
556
+ ui_state["flash"] = f"no older match for {ui_state['search']!r}"
557
+
558
+ @kb.add("s-tab")
559
+ def _(event):
560
+ if on_shift_tab:
561
+ ui_state["flash"] = on_shift_tab()
562
+
563
+ @kb.add("c-l")
564
+ def _(event):
565
+ event.app.renderer.clear()
566
+
567
+ @kb.add("?", filter=empty)
568
+ def _(event):
569
+ result["text"] = "/shortcuts"
570
+ event.app.exit()
571
+
572
+ @kb.add("c-space")
573
+ @kb.add("c-@")
574
+ def _(event):
575
+ result["text"] = "\x00voice\x00" + buf.text # the caller records, then opens the box again with the words
576
+ event.app.exit()
577
+
578
+ buf.on_text_changed += lambda _b: None if ui_state["setting"] else ui_state.update(sel=0, flash="", recalled=False)
579
+
580
+ @kb.add("c-p")
581
+ def _(event):
582
+ if not buf.text:
583
+ buf.insert_text("/")
584
+
585
+ @kb.add("c-c")
586
+ def _(event):
587
+ if buf.text:
588
+ buf.reset()
589
+ else:
590
+ event.app.exit()
591
+
592
+ @kb.add("c-q")
593
+ def _(event):
594
+ event.app.exit()
595
+
596
+ @kb.add("c-d")
597
+ def _(event):
598
+ if not buf.text:
599
+ event.app.exit()
600
+
601
+ input_win = Window(BufferControl(buf, input_processors=[ConditionalProcessor(AfterInput(PLACEHOLDER, style="class:placeholder"), filter=empty)]),
602
+ height=lambda: Dimension.exact(min(8, max(2, buf.text.count("\n") + 2))), wrap_lines=True)
603
+ prompt = Window(FormattedTextControl([("class:prompt", " \u276F ")]), width=3, dont_extend_width=True)
604
+ box = Frame(VSplit([prompt, input_win, Window(width=1)]), style="class:frame")
605
+ hints = Window(FormattedTextControl(keys), height=1, align=WindowAlign.CENTER)
606
+ menu_box = ConditionalContainer(Window(FormattedTextControl(menu), height=Dimension(min=1, max=12)), filter=menu_open)
607
+
608
+ def margins(container):
609
+ return VSplit([Window(width=1), container, Window(width=1)])
610
+ if not landing:
611
+ status = VSplit([Window(width=2), Window(FormattedTextControl(status_left), dont_extend_width=True), Window(FormattedTextControl(status_right), align=WindowAlign.RIGHT), Window(width=2)], height=1)
612
+ root = HSplit([status, margins(box), hints, margins(menu_box)])
613
+ else:
614
+ tall = Condition(lambda: (size().rows if size() else 30) >= 30) # a short terminal leaves the logo out, so the box still fits
615
+
616
+ def blurb():
617
+ return FormattedText([("class:hint", "Build and plan with fewer tokens. ") , ("class:hint", f"pal {version}")])
618
+
619
+ def setup_line():
620
+ return FormattedText(agent_line(session))
621
+
622
+ def folder_line():
623
+ return FormattedText(project_line(session))
624
+
625
+ def notice_line():
626
+ note = getattr(session, "notice", "")
627
+ return FormattedText([("class:warn", note[:160])] if note else [])
628
+
629
+ root = HSplit([Window(FormattedTextControl(lambda: FormattedText([("class:hint", " " + short_path(session.cwd))])), height=1),
630
+ Window(height=Dimension(weight=2)),
631
+ ConditionalContainer(HSplit([Window(FormattedTextControl(FormattedText(dot_logo())), height=len(BRAILLE), align=WindowAlign.CENTER), Window(height=2)]), filter=tall),
632
+ Window(FormattedTextControl(blurb), height=1, align=WindowAlign.CENTER),
633
+ Window(height=1),
634
+ Window(FormattedTextControl(setup_line), height=1, align=WindowAlign.CENTER),
635
+ Window(FormattedTextControl(folder_line), height=1, align=WindowAlign.CENTER),
636
+ ConditionalContainer(Window(FormattedTextControl(notice_line), height=1, align=WindowAlign.CENTER),
637
+ filter=Condition(lambda: bool(getattr(session, "notice", "")))),
638
+ Window(height=Dimension(weight=2)),
639
+ margins(box),
640
+ Window(height=1),
641
+ hints,
642
+ margins(menu_box),
643
+ Window(height=Dimension(weight=1))], style="class:screen")
644
+ app: Application = Application(layout=Layout(root, focused_element=input_win), key_bindings=kb, style=STYLE, full_screen=landing, erase_when_done=True, mouse_support=False,
645
+ refresh_interval=1.0 if landing else None) # the graph finishes in the background
646
+ app.run()
647
+ return result["text"]