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.
- pal/__init__.py +29 -0
- pal/__main__.py +3 -0
- pal/access_manager/__init__.py +16 -0
- pal/access_manager/components/__init__.py +14 -0
- pal/access_manager/components/access_control.py +41 -0
- pal/access_manager/components/base.py +39 -0
- pal/access_manager/components/intervention.py +31 -0
- pal/access_manager/components/privilege_registry.py +30 -0
- pal/access_manager/pipeline.py +68 -0
- pal/access_manager/utils/__init__.py +2 -0
- pal/agent_constraints/__init__.py +3 -0
- pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
- pal/agent_constraints/components/__init__.py +3 -0
- pal/agent_constraints/components/constraints.py +22 -0
- pal/agent_constraints/pipeline.py +22 -0
- pal/agent_constraints/utils/__init__.py +2 -0
- pal/agents/__init__.py +12 -0
- pal/agents/coding.py +14 -0
- pal/agents/docs.py +15 -0
- pal/chat/__init__.py +4 -0
- pal/chat/demo.py +231 -0
- pal/chat/providers.py +23 -0
- pal/chat/service.py +591 -0
- pal/chat/services/__init__.py +26 -0
- pal/chat/services/base.py +142 -0
- pal/chat/services/data.py +142 -0
- pal/chat/services/media.py +123 -0
- pal/chat/services/web.py +60 -0
- pal/chat/sessions.py +108 -0
- pal/chat/tools.py +446 -0
- pal/chat/uploads.py +81 -0
- pal/cli.py +502 -0
- pal/compression/__init__.py +12 -0
- pal/compression/components/__init__.py +64 -0
- pal/compression/components/adverbs.py +20 -0
- pal/compression/components/hedges.py +54 -0
- pal/compression/components/interjections.py +20 -0
- pal/compression/components/minifier.py +56 -0
- pal/compression/components/output_compressor.py +13 -0
- pal/compression/components/pleonasms.py +54 -0
- pal/compression/components/punctuations.py +36 -0
- pal/compression/components/stopwords.py +62 -0
- pal/compression/history.py +346 -0
- pal/compression/pipeline.py +58 -0
- pal/compression/utils/__init__.py +30 -0
- pal/compression/utils/negations.py +18 -0
- pal/compression/utils/nltk_setup.py +20 -0
- pal/compression/utils/pronouns.py +27 -0
- pal/compression/utils/proselint_setup.py +9 -0
- pal/compression/utils/spacy_setup.py +22 -0
- pal/config/default.yaml +163 -0
- pal/config.py +504 -0
- pal/context_manager/__init__.py +7 -0
- pal/context_manager/chat_context.py +117 -0
- pal/context_manager/components/__init__.py +5 -0
- pal/context_manager/components/assembler.py +75 -0
- pal/context_manager/components/context_cache.py +45 -0
- pal/context_manager/components/kv_bridge.py +16 -0
- pal/context_manager/components/pager.py +48 -0
- pal/context_manager/components/simple_context_manager.py +56 -0
- pal/context_manager/components/turns.py +88 -0
- pal/context_manager/pipeline.py +27 -0
- pal/context_manager/utils/__init__.py +18 -0
- pal/context_manager/utils/hashing.py +5 -0
- pal/context_manager/utils/relevance.py +59 -0
- pal/context_manager/utils/storage.py +37 -0
- pal/documents/__init__.py +4 -0
- pal/documents/components/__init__.py +0 -0
- pal/documents/components/chunker.py +79 -0
- pal/documents/components/index.py +155 -0
- pal/documents/utils/__init__.py +1 -0
- pal/errors.py +40 -0
- pal/input_converters/__init__.py +8 -0
- pal/input_converters/components/__init__.py +0 -0
- pal/input_converters/components/base.py +38 -0
- pal/input_converters/components/pandoc_converter.py +87 -0
- pal/input_converters/components/pdf_converter.py +46 -0
- pal/input_converters/components/pptx_converter.py +52 -0
- pal/input_converters/components/xlsx_converter.py +38 -0
- pal/input_converters/pipeline.py +72 -0
- pal/input_converters/utils/__init__.py +1 -0
- pal/input_converters/workspace.py +56 -0
- pal/input_savers/INPUT_RULES.md +2 -0
- pal/input_savers/__init__.py +5 -0
- pal/input_savers/components/__init__.py +0 -0
- pal/input_savers/components/base.py +40 -0
- pal/input_savers/components/methods.py +41 -0
- pal/input_savers/components/verifier.py +39 -0
- pal/input_savers/pipeline.py +154 -0
- pal/input_savers/utils/__init__.py +0 -0
- pal/input_savers/utils/guards.py +94 -0
- pal/knowledge_graph/__init__.py +10 -0
- pal/knowledge_graph/components/__init__.py +0 -0
- pal/knowledge_graph/components/base.py +50 -0
- pal/knowledge_graph/components/builder.py +54 -0
- pal/knowledge_graph/components/retriever.py +100 -0
- pal/knowledge_graph/components/workspace.py +116 -0
- pal/knowledge_graph/pipeline.py +215 -0
- pal/knowledge_graph/utils/__init__.py +1 -0
- pal/latency_tracker/__init__.py +3 -0
- pal/latency_tracker/pipeline.py +86 -0
- pal/llm_scheduler/__init__.py +11 -0
- pal/llm_scheduler/api.py +137 -0
- pal/llm_scheduler/code.py +28 -0
- pal/llm_scheduler/core.py +92 -0
- pal/llm_scheduler/local.py +215 -0
- pal/llm_scheduler/pipeline.py +42 -0
- pal/llm_scheduler/types.py +92 -0
- pal/llm_wrapper/__init__.py +34 -0
- pal/llm_wrapper/adapter.py +316 -0
- pal/llm_wrapper/components/__init__.py +23 -0
- pal/llm_wrapper/components/adapter_types.py +90 -0
- pal/llm_wrapper/components/base.py +195 -0
- pal/llm_wrapper/components/features.py +104 -0
- pal/llm_wrapper/components/litellm_wrapper.py +215 -0
- pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
- pal/llm_wrapper/modes.py +73 -0
- pal/llm_wrapper/pipeline.py +61 -0
- pal/llm_wrapper/utils/__init__.py +4 -0
- pal/llm_wrapper/utils/registry.py +49 -0
- pal/llm_wrapper/utils/token_counting.py +10 -0
- pal/local_llm/__init__.py +5 -0
- pal/local_llm/components/__init__.py +0 -0
- pal/local_llm/components/huggingface_local.py +217 -0
- pal/local_llm/components/local_llm.py +60 -0
- pal/logging_setup.py +18 -0
- pal/memory/__init__.py +7 -0
- pal/memory/components/__init__.py +6 -0
- pal/memory/components/lru_k.py +56 -0
- pal/memory/components/manager.py +98 -0
- pal/memory/components/note.py +54 -0
- pal/memory/components/storage_manager.py +60 -0
- pal/memory/pipeline.py +122 -0
- pal/memory/utils/__init__.py +3 -0
- pal/memory/utils/keyword_overlap.py +3 -0
- pal/memory_tracker/__init__.py +3 -0
- pal/memory_tracker/components/__init__.py +0 -0
- pal/memory_tracker/components/process.py +56 -0
- pal/memory_tracker/pipeline.py +44 -0
- pal/metrics/__init__.py +4 -0
- pal/metrics/components/__init__.py +4 -0
- pal/metrics/components/collector.py +140 -0
- pal/metrics/components/exporter.py +28 -0
- pal/metrics/pipeline.py +49 -0
- pal/metrics/utils/__init__.py +2 -0
- pal/notes_manager/__init__.py +5 -0
- pal/notes_manager/components/__init__.py +0 -0
- pal/notes_manager/components/privacy.py +14 -0
- pal/notes_manager/components/recall.py +31 -0
- pal/notes_manager/components/tidy.py +28 -0
- pal/notes_manager/pipeline.py +138 -0
- pal/orchestrator/__init__.py +26 -0
- pal/orchestrator/components/__init__.py +26 -0
- pal/orchestrator/components/agent.py +105 -0
- pal/orchestrator/components/cgroup_manager.py +209 -0
- pal/orchestrator/components/context_interrupt.py +220 -0
- pal/orchestrator/components/limits.py +9 -0
- pal/orchestrator/components/scheduler.py +69 -0
- pal/orchestrator/components/syscall_scheduler.py +200 -0
- pal/orchestrator/pipeline.py +228 -0
- pal/orchestrator/utils/__init__.py +4 -0
- pal/orchestrator/utils/device.py +164 -0
- pal/orchestrator/utils/protocol.py +32 -0
- pal/os_kernel/__init__.py +5 -0
- pal/os_kernel/filesystem.py +80 -0
- pal/os_kernel/network.py +146 -0
- pal/os_kernel/pipeline.py +175 -0
- pal/os_kernel/sysinfo.py +36 -0
- pal/paths.py +59 -0
- pal/pipeline.py +177 -0
- pal/py.typed +0 -0
- pal/redact.py +19 -0
- pal/sdk/__init__.py +33 -0
- pal/sdk/adapters.py +43 -0
- pal/sdk/agent.py +99 -0
- pal/sdk/components/__init__.py +14 -0
- pal/sdk/components/autogen_adapter.py +65 -0
- pal/sdk/components/autogen_client.py +98 -0
- pal/sdk/components/framework_adapter_base.py +69 -0
- pal/sdk/components/metagpt_adapter.py +46 -0
- pal/sdk/components/open_interpreter_adapter.py +47 -0
- pal/sdk/components/tool_catalog.py +44 -0
- pal/sdk/os_api.py +84 -0
- pal/sdk/patterns.py +129 -0
- pal/sdk/pipeline.py +240 -0
- pal/sdk/queries.py +68 -0
- pal/sdk/savers.py +88 -0
- pal/sdk/utils/__init__.py +2 -0
- pal/storage_manager/__init__.py +17 -0
- pal/storage_manager/components/__init__.py +19 -0
- pal/storage_manager/components/base.py +50 -0
- pal/storage_manager/components/local_file_backend.py +84 -0
- pal/storage_manager/components/storage_queue.py +91 -0
- pal/storage_manager/components/vector_store_backend.py +122 -0
- pal/storage_manager/pipeline.py +153 -0
- pal/storage_manager/utils/__init__.py +4 -0
- pal/storage_manager/utils/memory_adapter.py +60 -0
- pal/storage_manager/utils/sdk.py +93 -0
- pal/studio/__init__.py +0 -0
- pal/studio/builder.py +166 -0
- pal/studio/catalog.py +90 -0
- pal/studio/checks.py +129 -0
- pal/studio/clients.py +6 -0
- pal/studio/editor.py +137 -0
- pal/studio/files.py +6 -0
- pal/studio/models.py +6 -0
- pal/studio/plan.py +149 -0
- pal/studio/planner.py +205 -0
- pal/studio/policy.py +69 -0
- pal/studio/routes.py +254 -0
- pal/studio/runs.py +210 -0
- pal/task_router/__init__.py +11 -0
- pal/task_router/components/__init__.py +0 -0
- pal/task_router/components/answerer.py +37 -0
- pal/task_router/components/base.py +34 -0
- pal/task_router/components/classifier.py +40 -0
- pal/task_router/components/gates.py +23 -0
- pal/task_router/pipeline.py +61 -0
- pal/task_router/utils/__init__.py +1 -0
- pal/terminal/__init__.py +1 -0
- pal/terminal/backend.py +141 -0
- pal/terminal/bench.py +173 -0
- pal/terminal/cli.py +826 -0
- pal/terminal/clients.py +170 -0
- pal/terminal/commands.py +401 -0
- pal/terminal/decide.py +242 -0
- pal/terminal/files.py +55 -0
- pal/terminal/flow_cli.py +439 -0
- pal/terminal/hardware.py +199 -0
- pal/terminal/history.py +88 -0
- pal/terminal/keys.py +83 -0
- pal/terminal/local_models.py +261 -0
- pal/terminal/logo.py +117 -0
- pal/terminal/models.py +99 -0
- pal/terminal/policy.py +54 -0
- pal/terminal/project.py +413 -0
- pal/terminal/review.py +65 -0
- pal/terminal/security.py +239 -0
- pal/terminal/session.py +503 -0
- pal/terminal/setup.py +354 -0
- pal/terminal/syntax.py +98 -0
- pal/terminal/theme.py +37 -0
- pal/terminal/ui.py +647 -0
- pal/terminal/voice.py +137 -0
- pal/terminal/workflow.py +624 -0
- pal/token_tracker/__init__.py +3 -0
- pal/token_tracker/pipeline.py +54 -0
- pal/tokens.py +21 -0
- pal/tool_catalog.py +107 -0
- pal/tool_manager/__init__.py +17 -0
- pal/tool_manager/builtin.py +82 -0
- pal/tool_manager/components/__init__.py +19 -0
- pal/tool_manager/components/base.py +53 -0
- pal/tool_manager/components/conflict_resolver.py +27 -0
- pal/tool_manager/components/param_validator.py +41 -0
- pal/tool_manager/components/tool_call_queue.py +111 -0
- pal/tool_manager/components/tool_registry.py +60 -0
- pal/tool_manager/pipeline.py +80 -0
- pal/tool_manager/utils/__init__.py +3 -0
- pal/web/__init__.py +3 -0
- pal/web/app.py +276 -0
- pal/web/static/editor.js +219 -0
- pal/web/static/fonts/LICENSE-Inter.txt +3 -0
- pal/web/static/fonts/inter-400.woff2 +0 -0
- pal/web/static/fonts/inter-500.woff2 +0 -0
- pal/web/static/fonts/inter-600.woff2 +0 -0
- pal/web/static/fonts/inter-700.woff2 +0 -0
- pal/web/static/inter.css +5 -0
- pal/web/static/pal-logo-256.png +0 -0
- pal/web/static/pal-logo-32.png +0 -0
- pal/web/static/pal-logo-64.png +0 -0
- pal/web/static/studio.css +306 -0
- pal/web/static/studio.js +533 -0
- pal/web/static/voice.js +76 -0
- pal/web/templates/index.html +368 -0
- pal/web/templates/studio.html +120 -0
- pal_code-0.1.0.dist-info/METADATA +379 -0
- pal_code-0.1.0.dist-info/RECORD +282 -0
- pal_code-0.1.0.dist-info/WHEEL +5 -0
- pal_code-0.1.0.dist-info/entry_points.txt +2 -0
- pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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"]
|