super-code-assistant 3.3.6__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.
- commands/__init__.py +859 -0
- core/__init__.py +0 -0
- core/config.py +263 -0
- core/config_template.json +7 -0
- core/context.py +271 -0
- core/engine.py +635 -0
- core/file_state.py +279 -0
- core/llm.py +309 -0
- core/model_capabilities.py +45 -0
- core/permissions.py +204 -0
- core/sandbox/__init__.py +15 -0
- core/sandbox/blacklist.py +176 -0
- core/sandbox/config.py +38 -0
- core/sandbox/network.py +136 -0
- core/sandbox/path_protection.py +126 -0
- core/session.py +295 -0
- core/tool.py +45 -0
- features/__init__.py +0 -0
- features/compact.py +945 -0
- features/coordinator.py +105 -0
- features/cost_tracker.py +184 -0
- features/extract_memories.py +326 -0
- features/find_relevant_memories.py +376 -0
- features/git_ai.py +256 -0
- features/memory.py +531 -0
- features/memory_age.py +66 -0
- features/memory_scan.py +153 -0
- features/memory_types.py +34 -0
- features/plan.py +327 -0
- features/skills.py +300 -0
- features/worker_manager.py +232 -0
- mcp/__init__.py +0 -0
- mcp/client.py +112 -0
- mcp/loader.py +80 -0
- mcp/tool_proxy.py +59 -0
- super_code_assistant-3.3.6.dist-info/METADATA +45 -0
- super_code_assistant-3.3.6.dist-info/RECORD +61 -0
- super_code_assistant-3.3.6.dist-info/WHEEL +5 -0
- super_code_assistant-3.3.6.dist-info/entry_points.txt +2 -0
- super_code_assistant-3.3.6.dist-info/top_level.txt +7 -0
- tools/__init__.py +21 -0
- tools/agent.py +132 -0
- tools/ask_user.py +111 -0
- tools/bash.py +77 -0
- tools/file_edit.py +269 -0
- tools/file_read.py +206 -0
- tools/file_write.py +78 -0
- tools/glob_tool.py +81 -0
- tools/grep_tool.py +134 -0
- tools/plan_tools.py +75 -0
- tools/skill.py +108 -0
- tools/tool.py +44 -0
- tools/web_fetch.py +129 -0
- tools/web_search.py +220 -0
- tui/__init__.py +0 -0
- tui/app.py +726 -0
- tui/clipboard_image.py +42 -0
- tui/keylistener.py +140 -0
- tui/prompt.py +752 -0
- tui/query.py +200 -0
- tui/rendering.py +135 -0
tui/prompt.py
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
"""带边框的输入框 + 斜杠命令补全。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from prompt_toolkit.application import Application as PTApp
|
|
11
|
+
from prompt_toolkit.application.current import get_app
|
|
12
|
+
from prompt_toolkit.styles import Style
|
|
13
|
+
from prompt_toolkit.buffer import Buffer
|
|
14
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
15
|
+
from prompt_toolkit.document import Document
|
|
16
|
+
from prompt_toolkit.history import FileHistory
|
|
17
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
18
|
+
from prompt_toolkit.layout import Layout
|
|
19
|
+
from prompt_toolkit.layout.containers import HSplit, Window, FloatContainer, Float
|
|
20
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
21
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
22
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
23
|
+
from rich.cells import cell_len
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
# 图片拖拽支持(P0 多模态):扩展名 → MIME 类型
|
|
30
|
+
_IMAGE_EXT_TO_MIME = {
|
|
31
|
+
".png": "image/png",
|
|
32
|
+
".jpg": "image/jpeg",
|
|
33
|
+
".jpeg": "image/jpeg",
|
|
34
|
+
".gif": "image/gif",
|
|
35
|
+
".webp": "image/webp",
|
|
36
|
+
".bmp": "image/bmp",
|
|
37
|
+
}
|
|
38
|
+
_IMAGE_MAX_BYTES = 8 * 1024 * 1024 # 图片 8MB 上限,防止 base64 撑爆内存/上下文
|
|
39
|
+
|
|
40
|
+
# 多路径解析:先匹配引号包裹(含空格路径),再匹配裸词(空格分隔)。支持多文件拖入。
|
|
41
|
+
_IMAGE_PATHS_RE = re.compile(r'"([^"]+)"|(\S+)')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _extract_image_paths(text: str) -> list[str]:
|
|
45
|
+
"""提取文本中所有图片文件路径(支持多文件拖入:引号包裹+空格分隔混合)。
|
|
46
|
+
|
|
47
|
+
返回存在的图片路径列表(扩展名 + 存在性校验),非图片/不存在路径静默忽略。
|
|
48
|
+
"""
|
|
49
|
+
results = []
|
|
50
|
+
for quoted, bare in _IMAGE_PATHS_RE.findall(text):
|
|
51
|
+
candidate = (quoted or bare).strip().strip('"').strip("'").strip()
|
|
52
|
+
ext = os.path.splitext(candidate)[1].lower()
|
|
53
|
+
if ext in _IMAGE_EXT_TO_MIME and os.path.isfile(candidate):
|
|
54
|
+
results.append(candidate)
|
|
55
|
+
return results
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _register_image_data(media_type: str, data: str, name: str,
|
|
59
|
+
registry: list, images_ref: list, counter: int) -> None:
|
|
60
|
+
"""通用图片占位符注册:占位符文本 + base64 数据按序 append 到 images_ref。
|
|
61
|
+
|
|
62
|
+
registry 条目 (placeholder, name, "image"):_accept 时保留占位符文本,
|
|
63
|
+
数据由 images_ref 收集(拖拽 / 剪贴板共用此函数)。
|
|
64
|
+
"""
|
|
65
|
+
placeholder = f"[🖼 {counter}: {name}]"
|
|
66
|
+
registry.append((placeholder, name, "image"))
|
|
67
|
+
images_ref.append({"media_type": media_type, "data": data})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _register_image(path: str, registry: list, images_ref: list, counter: int) -> bool:
|
|
71
|
+
"""读取图片文件 → base64,注册图片占位符(保留占位符文本,数据按序 append 到 images_ref)。
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True 注册成功(调用方需自增 counter);超限/读取失败返回 False(路径文本保留原样)。
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
size = os.path.getsize(path)
|
|
78
|
+
except OSError:
|
|
79
|
+
return False
|
|
80
|
+
if size > _IMAGE_MAX_BYTES:
|
|
81
|
+
console.print(f"[yellow]⚠ 图片超过 8MB,未附加:{os.path.basename(path)}[/yellow]")
|
|
82
|
+
return False
|
|
83
|
+
try:
|
|
84
|
+
with open(path, "rb") as fh:
|
|
85
|
+
data = base64.b64encode(fh.read()).decode("utf-8")
|
|
86
|
+
except OSError:
|
|
87
|
+
return False
|
|
88
|
+
ext = os.path.splitext(path)[1].lower()
|
|
89
|
+
_register_image_data(
|
|
90
|
+
_IMAGE_EXT_TO_MIME.get(ext, "image/png"), data, os.path.basename(path),
|
|
91
|
+
registry, images_ref, counter,
|
|
92
|
+
)
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _register_clipboard_image(registry: list, images_ref: list, counter: int) -> bool:
|
|
97
|
+
"""探测剪贴板位图(PowerShell)→ 注册图片占位符。
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True 注册成功(调用方需自增 counter);无图/失败返回 False。
|
|
101
|
+
"""
|
|
102
|
+
from tui.clipboard_image import get_clipboard_image
|
|
103
|
+
got = get_clipboard_image()
|
|
104
|
+
if got is None:
|
|
105
|
+
return False
|
|
106
|
+
media_type, data = got
|
|
107
|
+
if len(data) // 4 * 3 > _IMAGE_MAX_BYTES: # base64 估算字节数
|
|
108
|
+
console.print("[yellow]⚠ 剪贴板图片超过 8MB,未附加[/yellow]")
|
|
109
|
+
return False
|
|
110
|
+
_register_image_data(media_type, data, f"clipboard-{counter}.png",
|
|
111
|
+
registry, images_ref, counter)
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cursor_distance_to_bottom() -> int:
|
|
116
|
+
"""返回光标到终端窗口底部的行数。获取失败返回一个大值(不添加 spacer)。"""
|
|
117
|
+
if sys.platform != 'win32':
|
|
118
|
+
return 999 # 非 Windows 暂不处理
|
|
119
|
+
|
|
120
|
+
import ctypes
|
|
121
|
+
from ctypes import wintypes
|
|
122
|
+
|
|
123
|
+
kernel32 = ctypes.windll.kernel32
|
|
124
|
+
h = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
|
125
|
+
|
|
126
|
+
class _COORD(ctypes.Structure):
|
|
127
|
+
_fields_ = [('X', wintypes.SHORT), ('Y', wintypes.SHORT)]
|
|
128
|
+
|
|
129
|
+
class _SMALL_RECT(ctypes.Structure):
|
|
130
|
+
_fields_ = [('Left', wintypes.SHORT), ('Top', wintypes.SHORT),
|
|
131
|
+
('Right', wintypes.SHORT), ('Bottom', wintypes.SHORT)]
|
|
132
|
+
|
|
133
|
+
class _CSBI(ctypes.Structure):
|
|
134
|
+
_fields_ = [('dwSize', _COORD), ('dwCursorPosition', _COORD),
|
|
135
|
+
('wAttributes', wintypes.WORD), ('srWindow', _SMALL_RECT),
|
|
136
|
+
('dwMaximumWindowSize', _COORD)]
|
|
137
|
+
|
|
138
|
+
csbi = _CSBI()
|
|
139
|
+
if kernel32.GetConsoleScreenBufferInfo(h, ctypes.byref(csbi)):
|
|
140
|
+
return max(0, csbi.srWindow.Bottom - csbi.dwCursorPosition.Y)
|
|
141
|
+
return 999
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class SlashCommandCompleter(Completer):
|
|
145
|
+
"""输入 / 时触发斜杠命令补全。"""
|
|
146
|
+
|
|
147
|
+
def get_completions(self, document: Document, complete_event):
|
|
148
|
+
text = document.text_before_cursor.lstrip()
|
|
149
|
+
if not text.startswith('/'):
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
query = text[1:].lower()
|
|
153
|
+
|
|
154
|
+
# 内置命令
|
|
155
|
+
from commands import _COMMAND_TABLE
|
|
156
|
+
for name, desc, _ in _COMMAND_TABLE:
|
|
157
|
+
if not query or name.startswith(query):
|
|
158
|
+
yield Completion(
|
|
159
|
+
f'/{name}',
|
|
160
|
+
start_position=-len(text),
|
|
161
|
+
display=f'/{name}',
|
|
162
|
+
display_meta=desc,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# 动态 skill 命令
|
|
166
|
+
try:
|
|
167
|
+
from features.skills import list_skills
|
|
168
|
+
builtin_names = {name for name, _, _ in _COMMAND_TABLE}
|
|
169
|
+
for skill in list_skills(user_invocable_only=True):
|
|
170
|
+
if skill.name in builtin_names:
|
|
171
|
+
continue
|
|
172
|
+
if not query or skill.name.startswith(query):
|
|
173
|
+
yield Completion(
|
|
174
|
+
f'/{skill.name}',
|
|
175
|
+
start_position=-len(text),
|
|
176
|
+
display=f'/{skill.name}',
|
|
177
|
+
display_meta=skill.description[:40] if skill.description else 'skill',
|
|
178
|
+
)
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
slash_completer = SlashCommandCompleter()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_completion_apply(current: str) -> bool:
|
|
187
|
+
"""检测文本变化是否来自补全应用(方向键选择 / 命令),而非用户粘贴/打字。
|
|
188
|
+
|
|
189
|
+
注意:**不能依赖 buf.complete_state**。pt 3.0.52 中方向键走
|
|
190
|
+
complete_next → go_to_completion → set_document → _text_changed(),
|
|
191
|
+
_text_changed 会先把 complete_state 置 None,再 fire on_text_changed,
|
|
192
|
+
回调返回后才恢复——回调时 complete_state 恒为 None,时序上拿不到。
|
|
193
|
+
改用「文本恰为完整斜杠命令名」判定:apply_completion 后 current 就是
|
|
194
|
+
某个补全项文本;手动逐字敲命令时 inserted 是单字符,本来就走不到
|
|
195
|
+
len(inserted)>=2 的剪贴板探测分支,两者不冲突。
|
|
196
|
+
|
|
197
|
+
若走 "疑似粘贴 → 剪贴板位图探测" 路径,每次按键都会 spawn 一个 PowerShell
|
|
198
|
+
进程(约 300-800ms),表现为按下方向键要等一会儿才跳到下一条命令。
|
|
199
|
+
返回 True 表示应跳过粘贴/剪贴板探测。
|
|
200
|
+
"""
|
|
201
|
+
if not current.startswith('/'):
|
|
202
|
+
return False
|
|
203
|
+
query = current[1:].lower()
|
|
204
|
+
from commands import _COMMAND_TABLE
|
|
205
|
+
for name, _, _ in _COMMAND_TABLE:
|
|
206
|
+
if name == query:
|
|
207
|
+
return True
|
|
208
|
+
try:
|
|
209
|
+
from features.skills import list_skills
|
|
210
|
+
for skill in list_skills(user_invocable_only=True):
|
|
211
|
+
if skill.name == query:
|
|
212
|
+
return True
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# 命令补全面板(输入 / 时弹出)配色:深青暗色系,替代 prompt_toolkit 默认灰底
|
|
219
|
+
_MENU_STYLE = Style.from_dict({
|
|
220
|
+
"completion-menu": "bg:#123a3a", # 面板背景
|
|
221
|
+
"completion-menu.completion": "bg:#1c4d4d", # 未选中条目
|
|
222
|
+
"completion-menu.completion.current": "bg:#14b8a6 fg:#000000", # 选中项亮青底黑字
|
|
223
|
+
"completion-menu.meta": "bg:#123a3a", # 描述区背景
|
|
224
|
+
"completion-menu.meta.completion": "bg:#1c4d4d",
|
|
225
|
+
"completion-menu.meta.completion.current": "bg:#14b8a6 fg:#000000",
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def bordered_prompt(
|
|
230
|
+
con: Console,
|
|
231
|
+
history: FileHistory | None = None,
|
|
232
|
+
completer: Completer | None = None,
|
|
233
|
+
mode_ref: list | None = None, # [False]=normal, [True]=plan,可变列表在闭包间共享状态
|
|
234
|
+
on_mode_toggle=None, # 切换模式时的回调,由 app.py 传入,负责调用 plan_manager
|
|
235
|
+
session_title: str = "", # 当前会话标题,/rename 设置后显示在上边框
|
|
236
|
+
ctx_usage: list | None = None, # [已用 token, 窗口 token],None 时不显示占用率
|
|
237
|
+
worker_status_cb: Callable[[], list[dict]] | None = None, # 返回运行中 worker 状态列表,None 时不显示进度面板
|
|
238
|
+
images_ref: list | None = None, # 图片附件收集列表:提交时图片占位符的 base64 数据按序 append 到这里
|
|
239
|
+
) -> str:
|
|
240
|
+
"""带上下边框的输入框,输入 / 时弹出补全菜单,Shift+Tab 切换模式。
|
|
241
|
+
|
|
242
|
+
Raises KeyboardInterrupt on Ctrl+C, EOFError on Ctrl+D with empty buffer.
|
|
243
|
+
"""
|
|
244
|
+
if mode_ref is None:
|
|
245
|
+
mode_ref = [False]
|
|
246
|
+
if images_ref is None:
|
|
247
|
+
images_ref = [] # 防御:未传时图片数据无处收集,_accept 会展开占位符文本
|
|
248
|
+
|
|
249
|
+
# ===== 粘贴占位符机制 =====
|
|
250
|
+
# 注册表:[(占位符文本, 原始内容, kind), ...]
|
|
251
|
+
# kind="text" → 文本粘贴,_accept 展开为原始内容
|
|
252
|
+
# kind="image" → 图片(附件),_accept 保留占位符文本,数据按序 append 到 images_ref
|
|
253
|
+
_paste_registry: list[tuple[str, str, str]] = []
|
|
254
|
+
_paste_counter = 0 # 自增编号,确保每个占位符唯一
|
|
255
|
+
_last_text = "" # 上一次 buffer 文本,用于 diff 检测
|
|
256
|
+
|
|
257
|
+
def _accept(b):
|
|
258
|
+
"""提交时展开文本占位符;图片占位符保持原位(数据走 images_ref 收集)。"""
|
|
259
|
+
text = b.text
|
|
260
|
+
for placeholder, actual, kind in _paste_registry:
|
|
261
|
+
if kind == "text":
|
|
262
|
+
text = text.replace(placeholder, actual)
|
|
263
|
+
get_app().exit(result=text)
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
buf = Buffer(
|
|
267
|
+
history=history,
|
|
268
|
+
completer=completer,
|
|
269
|
+
complete_while_typing=False,
|
|
270
|
+
accept_handler=_accept,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _trigger_completion_next_tick():
|
|
274
|
+
import asyncio
|
|
275
|
+
try:
|
|
276
|
+
loop = asyncio.get_event_loop()
|
|
277
|
+
loop.call_soon(lambda: buf.start_completion(select_first=False))
|
|
278
|
+
except RuntimeError:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
def _on_text_changed(_buf):
|
|
282
|
+
"""文本变化回调:检测多行粘贴并替换为占位符;同时处理 / 补全触发。"""
|
|
283
|
+
nonlocal _last_text, _paste_counter
|
|
284
|
+
|
|
285
|
+
current = _buf.text
|
|
286
|
+
|
|
287
|
+
# --- 补全应用检测(方向键选择 / 命令)---
|
|
288
|
+
# apply_completion 会把输入框文本替换为菜单中的某个补全项(如 "/" → "/memory")。
|
|
289
|
+
# 这种变化同样触发本回调;若走下方 "疑似粘贴 → 剪贴板位图探测"(len(inserted)>=2),
|
|
290
|
+
# 每次按键都会 spawn 一个 PowerShell 进程(约 300-800ms),表现为
|
|
291
|
+
# "按下方向键要等一会儿才跳到下一条命令"(单字符打字不探测,正好绕过)。
|
|
292
|
+
# 文本恰为完整斜杠命令 → 跳过粘贴/剪贴板探测 + 跳过末尾 start_completion 重算
|
|
293
|
+
# (菜单已在列表上,方向键移动不需要重建补全列表)。
|
|
294
|
+
if _is_completion_apply(current):
|
|
295
|
+
_last_text = current
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
# --- 粘贴检测:通过 diff 找到本次插入的文本 ---
|
|
299
|
+
if current != _last_text and len(current) > len(_last_text):
|
|
300
|
+
old = _last_text
|
|
301
|
+
# 找到新旧文本的公共前缀
|
|
302
|
+
i = 0
|
|
303
|
+
while i < len(old) and i < len(current) and old[i] == current[i]:
|
|
304
|
+
i += 1
|
|
305
|
+
# 找到新旧文本的公共后缀
|
|
306
|
+
j_old = len(old) - 1
|
|
307
|
+
j_new = len(current) - 1
|
|
308
|
+
while j_old >= i and j_new >= i and old[j_old] == current[j_new]:
|
|
309
|
+
j_old -= 1
|
|
310
|
+
j_new -= 1
|
|
311
|
+
inserted = current[i:j_new + 1]
|
|
312
|
+
|
|
313
|
+
# --- 图片拖拽检测(P0 多模态):插入的是图片文件路径(支持多文件)→ 替换为图片占位符 ---
|
|
314
|
+
if '\n' not in inserted:
|
|
315
|
+
img_paths = _extract_image_paths(inserted)
|
|
316
|
+
placeholders = []
|
|
317
|
+
for img_path in img_paths:
|
|
318
|
+
if _register_image(img_path, _paste_registry, images_ref, _paste_counter):
|
|
319
|
+
_paste_counter += 1
|
|
320
|
+
placeholders.append(_paste_registry[-1][0])
|
|
321
|
+
if placeholders:
|
|
322
|
+
new_text = current[:i] + " ".join(placeholders) + current[j_new + 1:]
|
|
323
|
+
_buf.on_text_changed -= _on_text_changed
|
|
324
|
+
try:
|
|
325
|
+
_buf.text = new_text
|
|
326
|
+
_buf.cursor_position = i + len(" ".join(placeholders))
|
|
327
|
+
finally:
|
|
328
|
+
_buf.on_text_changed += _on_text_changed
|
|
329
|
+
_last_text = new_text
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
# --- 剪贴板截图检测(P1 多模态):多字符插入(疑似粘贴)时探测剪贴板位图 ---
|
|
333
|
+
# 单字符打字不探测,避免每敲一键跑一次 PowerShell 卡输入
|
|
334
|
+
if len(inserted) >= 2 and _register_clipboard_image(
|
|
335
|
+
_paste_registry, images_ref, _paste_counter):
|
|
336
|
+
_paste_counter += 1
|
|
337
|
+
placeholder = _paste_registry[-1][0]
|
|
338
|
+
new_text = current[:i] + placeholder + current[j_new + 1:]
|
|
339
|
+
_buf.on_text_changed -= _on_text_changed
|
|
340
|
+
try:
|
|
341
|
+
_buf.text = new_text
|
|
342
|
+
_buf.cursor_position = i + len(placeholder)
|
|
343
|
+
finally:
|
|
344
|
+
_buf.on_text_changed += _on_text_changed
|
|
345
|
+
_last_text = new_text
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
# 多行插入 → 判定为粘贴,替换为占位符
|
|
349
|
+
if '\n' in inserted:
|
|
350
|
+
_paste_counter += 1
|
|
351
|
+
n_lines = inserted.count('\n') + 1
|
|
352
|
+
placeholder = f'[Pasted text #{_paste_counter} +{n_lines} lines]'
|
|
353
|
+
_paste_registry.append((placeholder, inserted, "text"))
|
|
354
|
+
|
|
355
|
+
# 用占位符替换粘贴内容(临时移除回调避免递归触发)
|
|
356
|
+
new_text = current[:i] + placeholder + current[j_new + 1:]
|
|
357
|
+
_buf.on_text_changed -= _on_text_changed
|
|
358
|
+
try:
|
|
359
|
+
_buf.text = new_text
|
|
360
|
+
_buf.cursor_position = i + len(placeholder)
|
|
361
|
+
finally:
|
|
362
|
+
_buf.on_text_changed += _on_text_changed
|
|
363
|
+
|
|
364
|
+
_last_text = new_text
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
_last_text = current
|
|
368
|
+
|
|
369
|
+
# 原有逻辑:输入 / 时触发补全
|
|
370
|
+
if current.lstrip().startswith('/'):
|
|
371
|
+
_trigger_completion_next_tick()
|
|
372
|
+
|
|
373
|
+
buf.on_text_changed += _on_text_changed
|
|
374
|
+
|
|
375
|
+
_BAR = "\u2500" # ─ box-drawing horizontal
|
|
376
|
+
_TITLE_PREFIX = " 📝 "
|
|
377
|
+
_TITLE_SUFFIX = " "
|
|
378
|
+
|
|
379
|
+
def _render_top_bar(width: int, title: str, color: str) -> list[tuple[str, str]]:
|
|
380
|
+
"""渲染上边框:有标题时居中显示 📝 {title},无标题时全 ─ 填充。"""
|
|
381
|
+
if not title:
|
|
382
|
+
fill = _BAR * max(0, width - 1)
|
|
383
|
+
return [(color, f'{_BAR}{fill}')]
|
|
384
|
+
|
|
385
|
+
min_frame = 4 # 标题两端至少保留 ──(各 2 字符)
|
|
386
|
+
# 标题最大列宽 = 终端宽度 - 边框开销(cell_len 按显示列宽计,中文/emoji 占 2 列,
|
|
387
|
+
# len() 只数字符数会低估宽度导致标题溢出边框),再加 0.6 比例上限,
|
|
388
|
+
# 防止超宽终端上标题撑满整条边框、左右 ─ 填充几乎消失。
|
|
389
|
+
max_title = min(width - cell_len(_TITLE_PREFIX) - cell_len(_TITLE_SUFFIX) - min_frame,
|
|
390
|
+
int(width * 0.6))
|
|
391
|
+
if max_title <= 0:
|
|
392
|
+
fill = _BAR * max(0, width - 1)
|
|
393
|
+
return [(color, f'{_BAR}{fill}')]
|
|
394
|
+
|
|
395
|
+
# 先清洗换行符再截断:自动标题取自第一条用户消息(session.py _generate_title),
|
|
396
|
+
# 多行消息会带 \n。若 \n 带入 FormattedText,右侧 ─ 填充会被挤到第二行,
|
|
397
|
+
# 而 _top 的 Window height=1 只显示第一行 → 上边框右半"缺一块"。
|
|
398
|
+
# 注意清洗必须在 truncate 之前:Rich truncate 按列宽截断时 \n 占 0 列,
|
|
399
|
+
# 截断后再 replace 成空格会放大列宽(0→1 列/个),照样撑爆 remaining。
|
|
400
|
+
_title_text = Text(title.replace("\r\n", " ").replace("\n", " ").replace("\r", " "))
|
|
401
|
+
_title_text.truncate(max_title, overflow="ellipsis") # 原地修改,返回 None,不能链式
|
|
402
|
+
display_title = _title_text.plain
|
|
403
|
+
title_segment = f"{_TITLE_PREFIX}{display_title}{_TITLE_SUFFIX}"
|
|
404
|
+
remaining = width - cell_len(title_segment) - 1 # -1 for leading ─
|
|
405
|
+
left_fill = max(0, remaining // 2)
|
|
406
|
+
right_fill = max(0, remaining - left_fill)
|
|
407
|
+
return [(color, _BAR + _BAR * left_fill + title_segment + _BAR * right_fill)]
|
|
408
|
+
|
|
409
|
+
def _top():
|
|
410
|
+
try:
|
|
411
|
+
w = os.get_terminal_size().columns
|
|
412
|
+
except OSError:
|
|
413
|
+
w = 80
|
|
414
|
+
color = 'bold fg:ansiyellow' if mode_ref[0] else 'bold fg:ansiwhite'
|
|
415
|
+
return _render_top_bar(w, session_title, color)
|
|
416
|
+
|
|
417
|
+
def _bot():
|
|
418
|
+
try:
|
|
419
|
+
w = os.get_terminal_size().columns
|
|
420
|
+
except OSError:
|
|
421
|
+
w = 80
|
|
422
|
+
# 左:模式标签;右:ctx 进度条 + 百分比;中间 ─ 填充。极简:无快捷键提示。
|
|
423
|
+
mode_label = f"{_BAR} [Plan Mode] " if mode_ref[0] else f"{_BAR} [Normal] "
|
|
424
|
+
base_color = 'fg:ansiyellow' if mode_ref[0] else 'fg:ansiwhite'
|
|
425
|
+
|
|
426
|
+
# 上下文占用率:使用最近一次 API 返回的 input_tokens(tokenizer 精确计数),
|
|
427
|
+
# 除以模型 context window 得到占用百分比。<70% 绿 / 70-90% 黄(逼近压缩阈值)/ >=90% 红。
|
|
428
|
+
# 1M 大窗口下小占用会截断成 0%,用 round 四舍五入、不足 1% 显示 <1%。
|
|
429
|
+
# 30 格 ▰/▱ 进度条(每格 3.3%)靠右,百分比在条后同色。
|
|
430
|
+
# 空心部分用同色调暗色 hex(prompt_toolkit 不支持 dim 属性,会 ValueError)。
|
|
431
|
+
_DIM_HEX = {'ansigreen': '#1f5e2a', 'ansiyellow': '#6b5e00', 'ansired': '#7a2020'}
|
|
432
|
+
ctx_filled, ctx_empty, ctx_pct, ctx_color, ctx_dim = "", "", "", None, None
|
|
433
|
+
if ctx_usage is not None and ctx_usage[0]:
|
|
434
|
+
used, window = ctx_usage[0], ctx_usage[1]
|
|
435
|
+
if window:
|
|
436
|
+
pct = min(100, round(used * 100 / window))
|
|
437
|
+
filled = round(pct / (100 / 30)) # 30 格,每格 3.3%
|
|
438
|
+
ctx_filled = "▰" * filled
|
|
439
|
+
ctx_empty = "▱" * (30 - filled)
|
|
440
|
+
ctx_pct = f" {pct}% " if pct else " <1% "
|
|
441
|
+
state = ('ansired' if pct >= 90
|
|
442
|
+
else 'ansiyellow' if pct >= 70
|
|
443
|
+
else 'ansigreen')
|
|
444
|
+
ctx_color = f'bold fg:{state}'
|
|
445
|
+
ctx_dim = f'fg:{_DIM_HEX[state]}'
|
|
446
|
+
|
|
447
|
+
right_extra = ctx_filled + ctx_empty + ctx_pct
|
|
448
|
+
fill = _BAR * max(0, w - 1 - len(mode_label) - len(right_extra))
|
|
449
|
+
segments: list[tuple[str, str]] = [(base_color, f'{_BAR}{mode_label}{fill}')]
|
|
450
|
+
if ctx_color:
|
|
451
|
+
segments.append((ctx_color, ctx_filled))
|
|
452
|
+
segments.append((ctx_dim, ctx_empty)) # 同色调暗色,整条颜色统一(实心亮/空心暗)
|
|
453
|
+
segments.append((ctx_color, ctx_pct))
|
|
454
|
+
segments.append((base_color, _BAR))
|
|
455
|
+
return segments
|
|
456
|
+
|
|
457
|
+
# ===== worker 进度面板(输入框上方,协调者模式常驻)=====
|
|
458
|
+
# 运行中 worker 全展示(彩色实时活动);完成的保留为 ✓/✗ 状态行不消失
|
|
459
|
+
# (输入框位置稳定,不在 worker 完成时跳动);完成行保留最近 _WORKER_DONE_MAX
|
|
460
|
+
# 个、更早的折叠;无任何 worker 时显示占位行。数据由外部回调注入(拉取式快照)。
|
|
461
|
+
_WORKER_DONE_MAX = 5 # 完成的 worker 最多保留展示的行数,更早的折叠
|
|
462
|
+
_WORKER_PANEL_MIN_WIDTH = 40 # 终端列宽低于此值时不显示面板(最小内容也会溢出)
|
|
463
|
+
_WORKER_ITEM_PREFIX = " ⚙ " # 条目前缀(box-drawing 竖线 + 齿轮,延续边框语言)
|
|
464
|
+
_WORKER_MORE_INDENT = " " # 折叠行缩进(对齐条目内容区,区别于 ⚙ 条目)
|
|
465
|
+
_WORKER_GRAY = '#888888' # 完成态灰色(prompt_toolkit 无 dim,用灰 hex)
|
|
466
|
+
_WORKER_DONE_GREEN = '#2e8b57' # ✓ 对勾暗绿(低调不抢视线)
|
|
467
|
+
|
|
468
|
+
def _render_worker_item(wk: dict, width: int) -> list[tuple[str, str]]:
|
|
469
|
+
"""单行条目:│ ⚙ 描述 · 状态/活动 · N tools,按状态分支配色。
|
|
470
|
+
|
|
471
|
+
running:描述 cyan、活动 yellow、工具数 green(实时动态最醒目);
|
|
472
|
+
completed/killed/failed:✓/✗ 状态色 + 灰色描述,整行弱化。
|
|
473
|
+
宽度用 cell_len 计显示列宽(中文/emoji 占 2 列),超宽截断。
|
|
474
|
+
"""
|
|
475
|
+
desc = (wk.get("description") or "Worker").strip()
|
|
476
|
+
tools = wk.get("tool_uses", 0)
|
|
477
|
+
tools_str = f"{tools} tools"
|
|
478
|
+
sep = " · "
|
|
479
|
+
prefix_w = cell_len(_WORKER_ITEM_PREFIX)
|
|
480
|
+
inner_budget = max(1, width - prefix_w)
|
|
481
|
+
status = wk.get("status", "running")
|
|
482
|
+
|
|
483
|
+
if status == "running":
|
|
484
|
+
act = (wk.get("activity") or "Idle").strip()
|
|
485
|
+
d = Text(desc)
|
|
486
|
+
d.truncate(min(24, inner_budget // 3), overflow="ellipsis")
|
|
487
|
+
d_text = d.plain
|
|
488
|
+
fixed_w = cell_len(tools_str) + cell_len(sep) * 2
|
|
489
|
+
a = Text(act)
|
|
490
|
+
a.truncate(max(4, inner_budget - cell_len(d_text) - fixed_w), overflow="ellipsis")
|
|
491
|
+
return [
|
|
492
|
+
('fg:ansiwhite', _WORKER_ITEM_PREFIX),
|
|
493
|
+
('fg:ansicyan', d_text + sep),
|
|
494
|
+
('fg:ansiyellow', a.plain + sep),
|
|
495
|
+
('fg:ansigreen', tools_str),
|
|
496
|
+
]
|
|
497
|
+
|
|
498
|
+
# 完成态:✓/✗ + 状态词 + 工具数,整行灰色弱化
|
|
499
|
+
if status == "completed":
|
|
500
|
+
mark, mark_style, state_text = "✓", f'fg:{_WORKER_DONE_GREEN}', "已完成"
|
|
501
|
+
elif status == "killed":
|
|
502
|
+
mark, mark_style, state_text = "✗", 'fg:ansiyellow', "已停止"
|
|
503
|
+
elif status == "failed":
|
|
504
|
+
mark, mark_style, state_text = "✗", 'fg:ansired', "失败"
|
|
505
|
+
else: # idle 等防御分支:无状态词
|
|
506
|
+
mark, mark_style, state_text = "", "", ""
|
|
507
|
+
fixed = cell_len(tools_str)
|
|
508
|
+
fixed += cell_len(sep) * 2 + cell_len(state_text) if state_text else cell_len(sep)
|
|
509
|
+
if mark:
|
|
510
|
+
fixed += cell_len(mark) + 1
|
|
511
|
+
d = Text(desc)
|
|
512
|
+
d.truncate(max(4, inner_budget - fixed), overflow="ellipsis")
|
|
513
|
+
d_text = d.plain
|
|
514
|
+
segments: list[tuple[str, str]] = [('fg:ansiwhite', _WORKER_ITEM_PREFIX)]
|
|
515
|
+
if mark:
|
|
516
|
+
segments.append((mark_style, mark + " "))
|
|
517
|
+
segments.append((_WORKER_GRAY, d_text))
|
|
518
|
+
if state_text:
|
|
519
|
+
segments.append((_WORKER_GRAY, sep + state_text))
|
|
520
|
+
segments.append((_WORKER_GRAY, sep + tools_str))
|
|
521
|
+
return segments
|
|
522
|
+
|
|
523
|
+
def _workers_panel() -> list[tuple[str, str]]:
|
|
524
|
+
"""面板 text callable:仅任务执行期/完成态保留期显示。
|
|
525
|
+
|
|
526
|
+
有运行中任务 → 运行中全展示 + 完成态保留最近 _WORKER_DONE_MAX 个;
|
|
527
|
+
全部结束后完成态继续保留,直到用户提交下一轮输入(主循环 clear_finished);
|
|
528
|
+
无任何记录(初始状态/已清除)→ 面板消失不占位。
|
|
529
|
+
不做标题条/边框线:上边框已有 ─ 线,再铺一条会视觉重复(用户实测反馈)。
|
|
530
|
+
条目自带 │ ⚙ 前缀 + 颜色分段,独立成行已足够区分。
|
|
531
|
+
"""
|
|
532
|
+
if worker_status_cb is None:
|
|
533
|
+
return [("", "")]
|
|
534
|
+
try:
|
|
535
|
+
w = os.get_terminal_size().columns
|
|
536
|
+
except OSError:
|
|
537
|
+
w = 80
|
|
538
|
+
try:
|
|
539
|
+
workers = worker_status_cb()
|
|
540
|
+
except Exception:
|
|
541
|
+
return [("", "")] # 渲染层防御:状态回调异常不应崩掉整个 REPL
|
|
542
|
+
if w < _WORKER_PANEL_MIN_WIDTH:
|
|
543
|
+
return [("", "")]
|
|
544
|
+
if not workers:
|
|
545
|
+
return [("", "")] # 无任务记录 → 面板消失,不占位
|
|
546
|
+
running = [wk for wk in workers if wk.get("status", "running") == "running"]
|
|
547
|
+
done = [wk for wk in workers if wk.get("status", "running") != "running"]
|
|
548
|
+
# 运行中全展示;完成的保留最近 _WORKER_DONE_MAX 个(spawn 序靠后 = 最近)
|
|
549
|
+
hidden = max(0, len(done) - _WORKER_DONE_MAX)
|
|
550
|
+
shown = running + (done[hidden:] if hidden else done)
|
|
551
|
+
segments: list[tuple[str, str]] = []
|
|
552
|
+
for i, wk in enumerate(shown):
|
|
553
|
+
segments.extend(_render_worker_item(wk, w))
|
|
554
|
+
if i < len(shown) - 1:
|
|
555
|
+
segments.append(("", "\n"))
|
|
556
|
+
if hidden:
|
|
557
|
+
segments.append(("", "\n"))
|
|
558
|
+
segments.append(('fg:ansiyellow',
|
|
559
|
+
f"{_WORKER_MORE_INDENT}… and {hidden} more"))
|
|
560
|
+
return segments
|
|
561
|
+
|
|
562
|
+
def _panel_height() -> Dimension:
|
|
563
|
+
"""面板高度 callable:未启用/终端过窄/无任务记录时 0;否则按行数取值。"""
|
|
564
|
+
if worker_status_cb is None:
|
|
565
|
+
return Dimension(min=0, preferred=0)
|
|
566
|
+
try:
|
|
567
|
+
w = os.get_terminal_size().columns
|
|
568
|
+
except OSError:
|
|
569
|
+
w = 80
|
|
570
|
+
try:
|
|
571
|
+
workers = worker_status_cb()
|
|
572
|
+
except Exception:
|
|
573
|
+
return Dimension(min=0, preferred=0)
|
|
574
|
+
if w < _WORKER_PANEL_MIN_WIDTH:
|
|
575
|
+
return Dimension(min=0, preferred=0)
|
|
576
|
+
if not workers:
|
|
577
|
+
return Dimension(min=0, preferred=0) # 无任务记录 → 面板消失
|
|
578
|
+
n_running = sum(1 for wk in workers if wk.get("status", "running") == "running")
|
|
579
|
+
n_done = len(workers) - n_running
|
|
580
|
+
n = n_running + min(n_done, _WORKER_DONE_MAX) # 运行中全展示 + 最近完成
|
|
581
|
+
if n_done > _WORKER_DONE_MAX:
|
|
582
|
+
n += 1 # 折叠行
|
|
583
|
+
return Dimension(min=1, preferred=n, max=n)
|
|
584
|
+
|
|
585
|
+
def _line_prefix(lineno, wrap_count):
|
|
586
|
+
if lineno == 0 and wrap_count == 0:
|
|
587
|
+
color = 'bold fg:ansiyellow' if mode_ref[0] else 'bold fg:ansiwhite'
|
|
588
|
+
return [(color, '> ')]
|
|
589
|
+
return [('', ' ')]
|
|
590
|
+
|
|
591
|
+
_MENU_MAX = 8 # CompletionsMenu max_height
|
|
592
|
+
_distance = _cursor_distance_to_bottom()
|
|
593
|
+
_spacer_height = max(0, _MENU_MAX - _distance) if _distance < _MENU_MAX else 0
|
|
594
|
+
|
|
595
|
+
_body_windows = [
|
|
596
|
+
# worker 进度面板:置于整个输入组件最顶部(上边框之外),
|
|
597
|
+
# 与输入区之间由现有上边框线自然分隔,独立区块感更强
|
|
598
|
+
Window(FormattedTextControl(_workers_panel), height=_panel_height,
|
|
599
|
+
dont_extend_height=True),
|
|
600
|
+
Window(FormattedTextControl(_top), height=1, dont_extend_height=True),
|
|
601
|
+
Window(
|
|
602
|
+
BufferControl(buffer=buf),
|
|
603
|
+
get_line_prefix=_line_prefix,
|
|
604
|
+
height=Dimension(min=1),
|
|
605
|
+
dont_extend_height=True,
|
|
606
|
+
wrap_lines=True,
|
|
607
|
+
),
|
|
608
|
+
Window(FormattedTextControl(_bot), dont_extend_height=True),
|
|
609
|
+
]
|
|
610
|
+
if _spacer_height > 0:
|
|
611
|
+
_body_windows.append(
|
|
612
|
+
Window(FormattedTextControl(lambda: [("", "")]),
|
|
613
|
+
height=_spacer_height, dont_extend_height=True),
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
body = HSplit(_body_windows)
|
|
617
|
+
|
|
618
|
+
root = FloatContainer(
|
|
619
|
+
content=body,
|
|
620
|
+
floats=[
|
|
621
|
+
Float(
|
|
622
|
+
xcursor=True, ycursor=True,
|
|
623
|
+
content=CompletionsMenu(max_height=8, scroll_offset=1),
|
|
624
|
+
),
|
|
625
|
+
],
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
kb = KeyBindings()
|
|
629
|
+
|
|
630
|
+
@kb.add('enter')
|
|
631
|
+
def _(event):
|
|
632
|
+
buf.validate_and_handle()
|
|
633
|
+
|
|
634
|
+
@kb.add('c-c')
|
|
635
|
+
def _(event):
|
|
636
|
+
event.app.exit(exception=KeyboardInterrupt())
|
|
637
|
+
|
|
638
|
+
@kb.add('s-tab')
|
|
639
|
+
def _(event):
|
|
640
|
+
"""Shift+Tab 切换 plan mode:调用外部回调(负责 engine 切换),再刷新 UI。"""
|
|
641
|
+
if on_mode_toggle:
|
|
642
|
+
on_mode_toggle()
|
|
643
|
+
else:
|
|
644
|
+
mode_ref[0] = not mode_ref[0]
|
|
645
|
+
event.app.invalidate()
|
|
646
|
+
|
|
647
|
+
@kb.add('c-d')
|
|
648
|
+
def _(event):
|
|
649
|
+
if not buf.text:
|
|
650
|
+
event.app.exit(exception=EOFError())
|
|
651
|
+
|
|
652
|
+
@kb.add('backspace')
|
|
653
|
+
def _(event):
|
|
654
|
+
"""退格键:若光标前紧邻粘贴占位符,则一次性删除整个占位符;
|
|
655
|
+
否则执行默认单字符删除。"""
|
|
656
|
+
cursor_pos = buf.cursor_position
|
|
657
|
+
text_before = buf.text[:cursor_pos]
|
|
658
|
+
# 检查光标前是否以某个占位符结尾
|
|
659
|
+
for placeholder, _actual, _kind in _paste_registry:
|
|
660
|
+
if text_before.endswith(placeholder):
|
|
661
|
+
# 一次性删除整个占位符
|
|
662
|
+
new_text = buf.text[:cursor_pos - len(placeholder)] + buf.text[cursor_pos:]
|
|
663
|
+
buf.text = new_text
|
|
664
|
+
buf.cursor_position = cursor_pos - len(placeholder)
|
|
665
|
+
# 从注册表移除该占位符(保留其余条目 kind 字段)
|
|
666
|
+
_paste_registry[:] = [(p, a, k) for p, a, k in _paste_registry if p != placeholder]
|
|
667
|
+
return
|
|
668
|
+
# 非占位符:执行默认单字符删除
|
|
669
|
+
buf.delete_before_cursor(1)
|
|
670
|
+
|
|
671
|
+
# refresh_interval:面板心跳。worker 在后台线程更新状态,bordered_prompt 阻塞在
|
|
672
|
+
# app.run() 期间没有其他事件源,必须靠定时 invalidate 才能把 get_running_status()
|
|
673
|
+
# 的最新快照重绘出来。1s 间隔:worker 活动变化粒度是秒级,够跟手且开销可忽略。
|
|
674
|
+
# 用 prompt_toolkit 原生 refresh_interval(内部 async 任务随 run_async 自动启停),
|
|
675
|
+
# 普通模式(cb=None)保持 None = 事件驱动,零空转、零影响。
|
|
676
|
+
app = PTApp(
|
|
677
|
+
layout=Layout(root),
|
|
678
|
+
key_bindings=kb,
|
|
679
|
+
full_screen=False,
|
|
680
|
+
style=_MENU_STYLE, # 命令补全面板(输入 / 时弹出)用深青色,替换默认灰底
|
|
681
|
+
refresh_interval=1.0 if worker_status_cb is not None else None,
|
|
682
|
+
)
|
|
683
|
+
app.layout.focus(buf)
|
|
684
|
+
try:
|
|
685
|
+
return app.run()
|
|
686
|
+
finally:
|
|
687
|
+
# 提交后清理 spacer 区域:上移光标 → 清除到屏幕底,后续输出紧贴下边框
|
|
688
|
+
if _spacer_height > 0:
|
|
689
|
+
sys.stdout.write(f'\033[{_spacer_height}A')
|
|
690
|
+
sys.stdout.write('\033[J')
|
|
691
|
+
sys.stdout.flush()
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def pick_session(sessions: list) -> "object | None":
|
|
695
|
+
"""交互式会话选择器:上下方向键移动,回车确认,q/Esc 取消。
|
|
696
|
+
|
|
697
|
+
参数 sessions: list[SessionMeta],按 updated_at 降序排列(最新在前)。
|
|
698
|
+
返回选中的 SessionMeta,取消返回 None。
|
|
699
|
+
"""
|
|
700
|
+
if not sessions:
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
state = {"idx": 0} # 用 dict 让内层函数可修改
|
|
704
|
+
|
|
705
|
+
def _render_lines():
|
|
706
|
+
"""生成列表每行的 FormattedText 片段。"""
|
|
707
|
+
lines = []
|
|
708
|
+
for i, meta in enumerate(sessions):
|
|
709
|
+
from core.session import format_local_time
|
|
710
|
+
updated = format_local_time(meta.updated_at, "%Y-%m-%d %H:%M")
|
|
711
|
+
title = (meta.title or "Untitled")[:50]
|
|
712
|
+
label = f" {updated} {title}"
|
|
713
|
+
if i == state["idx"]:
|
|
714
|
+
lines.append(("bold fg:ansigreen", f"> {label}\n"))
|
|
715
|
+
else:
|
|
716
|
+
lines.append(("", f" {label}\n"))
|
|
717
|
+
return lines
|
|
718
|
+
|
|
719
|
+
kb = KeyBindings()
|
|
720
|
+
|
|
721
|
+
@kb.add("up")
|
|
722
|
+
def _(event):
|
|
723
|
+
state["idx"] = max(0, state["idx"] - 1)
|
|
724
|
+
event.app.invalidate()
|
|
725
|
+
|
|
726
|
+
@kb.add("down")
|
|
727
|
+
def _(event):
|
|
728
|
+
state["idx"] = min(len(sessions) - 1, state["idx"] + 1)
|
|
729
|
+
event.app.invalidate()
|
|
730
|
+
|
|
731
|
+
@kb.add("enter")
|
|
732
|
+
def _(event):
|
|
733
|
+
event.app.exit(result=sessions[state["idx"]])
|
|
734
|
+
|
|
735
|
+
@kb.add("q")
|
|
736
|
+
@kb.add("c-c")
|
|
737
|
+
@kb.add("escape")
|
|
738
|
+
def _(event):
|
|
739
|
+
event.app.exit(result=None)
|
|
740
|
+
|
|
741
|
+
header = Window(
|
|
742
|
+
FormattedTextControl(lambda: [("bold", "Select a session (↑↓ move · Enter confirm · q cancel)\n")]),
|
|
743
|
+
height=1, dont_extend_height=True,
|
|
744
|
+
)
|
|
745
|
+
body = Window(
|
|
746
|
+
FormattedTextControl(_render_lines),
|
|
747
|
+
dont_extend_height=False,
|
|
748
|
+
)
|
|
749
|
+
layout = Layout(HSplit([header, body]))
|
|
750
|
+
|
|
751
|
+
app = PTApp(layout=layout, key_bindings=kb, full_screen=False, refresh_interval=None)
|
|
752
|
+
return app.run()
|