noah-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.
- noah_code/__init__.py +3 -0
- noah_code/__main__.py +6 -0
- noah_code/agent.py +378 -0
- noah_code/approvals.py +105 -0
- noah_code/cli.py +422 -0
- noah_code/commands.py +70 -0
- noah_code/config.py +279 -0
- noah_code/custom_commands.py +103 -0
- noah_code/event_bridge.py +132 -0
- noah_code/events.py +27 -0
- noah_code/host.py +662 -0
- noah_code/macos_sandbox.py +142 -0
- noah_code/mcp_setup.py +91 -0
- noah_code/permissions.py +400 -0
- noah_code/sessions.py +157 -0
- noah_code/skills_setup.py +51 -0
- noah_code/snapshots.py +313 -0
- noah_code/tools/__init__.py +6 -0
- noah_code/tools/git_tools.py +44 -0
- noah_code/tools/workspace_tools.py +269 -0
- noah_code/ui/__init__.py +6 -0
- noah_code/ui/console.py +88 -0
- noah_code/ui/protocol.py +33 -0
- noah_code/ui/textual.css +9 -0
- noah_code/ui/textual_app.py +435 -0
- noah_code/updates.py +184 -0
- noah_code/workspace.py +49 -0
- noah_code-0.1.0.dist-info/METADATA +173 -0
- noah_code-0.1.0.dist-info/RECORD +31 -0
- noah_code-0.1.0.dist-info/WHEEL +4 -0
- noah_code-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Textual full-screen UI for Noah Code - thin client of AgentHost."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from textual import on, work
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal, Vertical
|
|
13
|
+
from textual.message import Message
|
|
14
|
+
from textual.screen import ModalScreen
|
|
15
|
+
from textual.widgets import (
|
|
16
|
+
Button,
|
|
17
|
+
Footer,
|
|
18
|
+
Input,
|
|
19
|
+
Label,
|
|
20
|
+
RichLog,
|
|
21
|
+
Static,
|
|
22
|
+
TextArea,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from noah_code.approvals import ApprovalChoice, ApprovalRequest
|
|
26
|
+
from noah_code.commands import all_command_names, help_text
|
|
27
|
+
from noah_code.events import HostEvent, HostEventKind
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from noah_code.host import AgentHost
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HostEventMessage(Message):
|
|
34
|
+
"""Posted when the host wants the TUI to render an event."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, event: HostEvent) -> None:
|
|
37
|
+
super().__init__()
|
|
38
|
+
self.event = event
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ApprovalModal(ModalScreen[ApprovalChoice]):
|
|
42
|
+
"""Ask once / session / reject for a permission decision."""
|
|
43
|
+
|
|
44
|
+
BINDINGS = [
|
|
45
|
+
Binding("1", "once", "Once", show=True),
|
|
46
|
+
Binding("2", "session", "Session", show=True),
|
|
47
|
+
Binding("3", "reject", "Reject", show=True),
|
|
48
|
+
Binding("escape", "reject", "Reject", show=False),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
def __init__(self, request: ApprovalRequest) -> None:
|
|
52
|
+
super().__init__()
|
|
53
|
+
self.request = request
|
|
54
|
+
|
|
55
|
+
def compose(self) -> ComposeResult:
|
|
56
|
+
d = self.request.decision
|
|
57
|
+
with Vertical(id="approval-dialog"):
|
|
58
|
+
yield Label(f"Approval {self.request.id[:8]}", id="approval-title")
|
|
59
|
+
yield Static(
|
|
60
|
+
f"[bold]{d.category}[/bold] {d.target}\n{d.reason}\n"
|
|
61
|
+
f"remember: {d.remember_pattern}",
|
|
62
|
+
id="approval-body",
|
|
63
|
+
)
|
|
64
|
+
with Horizontal(id="approval-buttons"):
|
|
65
|
+
yield Button("Once [1]", id="once", variant="primary")
|
|
66
|
+
yield Button("Session [2]", id="session", variant="success")
|
|
67
|
+
yield Button("Reject [3]", id="reject", variant="error")
|
|
68
|
+
|
|
69
|
+
def action_once(self) -> None:
|
|
70
|
+
self.dismiss(ApprovalChoice.ONCE)
|
|
71
|
+
|
|
72
|
+
def action_session(self) -> None:
|
|
73
|
+
self.dismiss(ApprovalChoice.SESSION)
|
|
74
|
+
|
|
75
|
+
def action_reject(self) -> None:
|
|
76
|
+
self.dismiss(ApprovalChoice.REJECT)
|
|
77
|
+
|
|
78
|
+
@on(Button.Pressed, "#once")
|
|
79
|
+
def _once(self) -> None:
|
|
80
|
+
self.dismiss(ApprovalChoice.ONCE)
|
|
81
|
+
|
|
82
|
+
@on(Button.Pressed, "#session")
|
|
83
|
+
def _session(self) -> None:
|
|
84
|
+
self.dismiss(ApprovalChoice.SESSION)
|
|
85
|
+
|
|
86
|
+
@on(Button.Pressed, "#reject")
|
|
87
|
+
def _reject(self) -> None:
|
|
88
|
+
self.dismiss(ApprovalChoice.REJECT)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CommandPalette(ModalScreen[str | None]):
|
|
92
|
+
"""Lightweight slash-command picker."""
|
|
93
|
+
|
|
94
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel", show=True)]
|
|
95
|
+
|
|
96
|
+
def __init__(self, commands: list[str] | None = None) -> None:
|
|
97
|
+
super().__init__()
|
|
98
|
+
self._all = commands or []
|
|
99
|
+
|
|
100
|
+
def compose(self) -> ComposeResult:
|
|
101
|
+
with Vertical(id="palette-dialog"):
|
|
102
|
+
yield Label("Commands", id="palette-title")
|
|
103
|
+
yield Input(placeholder="filter…", id="palette-filter")
|
|
104
|
+
yield RichLog(id="palette-list", markup=True, highlight=False)
|
|
105
|
+
yield Static("Enter to insert · Esc to close", id="palette-hint")
|
|
106
|
+
|
|
107
|
+
def on_mount(self) -> None:
|
|
108
|
+
self._choices = list(self._all) if self._all else all_command_names()
|
|
109
|
+
self._filtered = list(self._choices)
|
|
110
|
+
self._refresh()
|
|
111
|
+
self.query_one("#palette-filter", Input).focus()
|
|
112
|
+
|
|
113
|
+
def _refresh(self) -> None:
|
|
114
|
+
log = self.query_one("#palette-list", RichLog)
|
|
115
|
+
log.clear()
|
|
116
|
+
for item in self._filtered[:40]:
|
|
117
|
+
log.write(item)
|
|
118
|
+
|
|
119
|
+
@on(Input.Changed, "#palette-filter")
|
|
120
|
+
def _filter(self, event: Input.Changed) -> None:
|
|
121
|
+
q = event.value.strip().lower().lstrip("/")
|
|
122
|
+
self._filtered = [c for c in self._choices if q in c.lower()] if q else list(self._choices)
|
|
123
|
+
self._refresh()
|
|
124
|
+
|
|
125
|
+
@on(Input.Submitted, "#palette-filter")
|
|
126
|
+
def _submit(self, event: Input.Submitted) -> None:
|
|
127
|
+
if self._filtered:
|
|
128
|
+
self.dismiss(self._filtered[0] + " ")
|
|
129
|
+
else:
|
|
130
|
+
self.dismiss(None)
|
|
131
|
+
|
|
132
|
+
def action_cancel(self) -> None:
|
|
133
|
+
self.dismiss(None)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SessionPicker(ModalScreen[str | None]):
|
|
137
|
+
"""Pick a session id to switch to."""
|
|
138
|
+
|
|
139
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel", show=True)]
|
|
140
|
+
|
|
141
|
+
def __init__(self, rows: list[tuple[str, str]]) -> None:
|
|
142
|
+
super().__init__()
|
|
143
|
+
self._rows = rows # (id, label)
|
|
144
|
+
|
|
145
|
+
def compose(self) -> ComposeResult:
|
|
146
|
+
with Vertical(id="palette-dialog"):
|
|
147
|
+
yield Label("Sessions", id="palette-title")
|
|
148
|
+
yield Input(placeholder="filter or enter id…", id="palette-filter")
|
|
149
|
+
yield RichLog(id="palette-list", markup=True, highlight=False)
|
|
150
|
+
yield Static("Enter selects first match · Esc cancel", id="palette-hint")
|
|
151
|
+
|
|
152
|
+
def on_mount(self) -> None:
|
|
153
|
+
self._filtered = list(self._rows)
|
|
154
|
+
self._refresh()
|
|
155
|
+
self.query_one("#palette-filter", Input).focus()
|
|
156
|
+
|
|
157
|
+
def _refresh(self) -> None:
|
|
158
|
+
log = self.query_one("#palette-list", RichLog)
|
|
159
|
+
log.clear()
|
|
160
|
+
for sid, label in self._filtered[:40]:
|
|
161
|
+
log.write(f"{sid} {label}")
|
|
162
|
+
|
|
163
|
+
@on(Input.Changed, "#palette-filter")
|
|
164
|
+
def _filter(self, event: Input.Changed) -> None:
|
|
165
|
+
q = event.value.strip().lower()
|
|
166
|
+
if not q:
|
|
167
|
+
self._filtered = list(self._rows)
|
|
168
|
+
else:
|
|
169
|
+
self._filtered = [
|
|
170
|
+
(sid, label) for sid, label in self._rows if q in sid.lower() or q in label.lower()
|
|
171
|
+
]
|
|
172
|
+
self._refresh()
|
|
173
|
+
|
|
174
|
+
@on(Input.Submitted, "#palette-filter")
|
|
175
|
+
def _submit(self, event: Input.Submitted) -> None:
|
|
176
|
+
typed = event.value.strip()
|
|
177
|
+
if self._filtered:
|
|
178
|
+
self.dismiss(self._filtered[0][0])
|
|
179
|
+
elif typed:
|
|
180
|
+
self.dismiss(typed)
|
|
181
|
+
else:
|
|
182
|
+
self.dismiss(None)
|
|
183
|
+
|
|
184
|
+
def action_cancel(self) -> None:
|
|
185
|
+
self.dismiss(None)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class TextualUI:
|
|
189
|
+
"""HostUI implementation backed by NoahCodeApp."""
|
|
190
|
+
|
|
191
|
+
def __init__(self) -> None:
|
|
192
|
+
self._app: NoahCodeApp | None = None
|
|
193
|
+
self._status = ""
|
|
194
|
+
self._busy = False
|
|
195
|
+
|
|
196
|
+
def bind_app(self, app: NoahCodeApp) -> None:
|
|
197
|
+
self._app = app
|
|
198
|
+
|
|
199
|
+
def set_status(self, text: str) -> None:
|
|
200
|
+
self._status = text
|
|
201
|
+
if self._app is not None:
|
|
202
|
+
self._app.update_status_bar()
|
|
203
|
+
|
|
204
|
+
def set_busy(self, busy: bool) -> None:
|
|
205
|
+
self._busy = busy
|
|
206
|
+
if self._app is not None:
|
|
207
|
+
self._app.update_status_bar()
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def busy(self) -> bool:
|
|
211
|
+
return self._busy
|
|
212
|
+
|
|
213
|
+
def render(self, event: HostEvent) -> None:
|
|
214
|
+
if self._app is None:
|
|
215
|
+
return
|
|
216
|
+
# Safe from worker threads / async tasks on the app loop.
|
|
217
|
+
self._app.post_message(HostEventMessage(event))
|
|
218
|
+
|
|
219
|
+
async def ask_approval(self, request: ApprovalRequest) -> ApprovalChoice:
|
|
220
|
+
if self._app is None:
|
|
221
|
+
return ApprovalChoice.REJECT
|
|
222
|
+
return await self._app.request_approval(request)
|
|
223
|
+
|
|
224
|
+
async def prompt(self, status: str) -> str | None:
|
|
225
|
+
"""Unused in TUI mode (app owns input); kept for HostUI protocol."""
|
|
226
|
+
self.set_status(status)
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class NoahCodeApp(App[None]):
|
|
231
|
+
"""Full-screen coding session UI."""
|
|
232
|
+
|
|
233
|
+
TITLE = "Noah Code"
|
|
234
|
+
# Embedded CSS only - avoid CSS_PATH variables clobbering Textual design tokens.
|
|
235
|
+
CSS = """
|
|
236
|
+
Screen { background: #14161a; color: #e6e8eb; }
|
|
237
|
+
#status-bar {
|
|
238
|
+
dock: top; height: 1; background: #1c1f26; color: #e6e8eb;
|
|
239
|
+
text-style: bold; padding: 0 1; border-bottom: solid #2a303a;
|
|
240
|
+
}
|
|
241
|
+
#conversation {
|
|
242
|
+
height: 1fr; background: #14161a; border: none; padding: 0 1;
|
|
243
|
+
}
|
|
244
|
+
#input-hint { height: 1; color: #8b939e; padding: 0 1; background: #1c1f26; }
|
|
245
|
+
#composer {
|
|
246
|
+
height: 6; min-height: 4; max-height: 12;
|
|
247
|
+
border: solid #2a303a; background: #1c1f26; padding: 0 1;
|
|
248
|
+
}
|
|
249
|
+
#composer:focus { border: solid #5b9fd4; }
|
|
250
|
+
Footer { background: #1c1f26; color: #8b939e; }
|
|
251
|
+
ApprovalModal { align: center middle; }
|
|
252
|
+
#approval-dialog {
|
|
253
|
+
width: 72; max-width: 90%; height: auto; background: #1c1f26;
|
|
254
|
+
border: solid #5b9fd4; padding: 1 2;
|
|
255
|
+
}
|
|
256
|
+
#approval-title { text-style: bold; color: #5b9fd4; margin-bottom: 1; }
|
|
257
|
+
#approval-body { margin-bottom: 1; }
|
|
258
|
+
#approval-buttons { height: auto; align: center middle; }
|
|
259
|
+
#approval-buttons Button { margin: 0 1; }
|
|
260
|
+
CommandPalette { align: center middle; }
|
|
261
|
+
#palette-dialog {
|
|
262
|
+
width: 60; max-width: 90%; height: 20; background: #1c1f26;
|
|
263
|
+
border: solid #2a303a; padding: 1 2;
|
|
264
|
+
}
|
|
265
|
+
#palette-title { text-style: bold; margin-bottom: 1; }
|
|
266
|
+
#palette-filter { margin-bottom: 1; }
|
|
267
|
+
#palette-list { height: 1fr; border: none; background: #14161a; }
|
|
268
|
+
#palette-hint { color: #8b939e; margin-top: 1; }
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
BINDINGS = [
|
|
272
|
+
Binding("ctrl+q", "quit_app", "Quit", show=True),
|
|
273
|
+
Binding("ctrl+c", "cancel_or_quit", "Cancel", show=True),
|
|
274
|
+
Binding("ctrl+enter", "submit", "Send", show=True),
|
|
275
|
+
Binding("ctrl+p", "palette", "Commands", show=True),
|
|
276
|
+
Binding("ctrl+o", "sessions", "Sessions", show=True),
|
|
277
|
+
Binding("ctrl+n", "new_session", "New", show=True),
|
|
278
|
+
Binding("f1", "show_help", "Help", show=True),
|
|
279
|
+
Binding("question_mark", "show_help", "Help", show=False),
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
def __init__(self, host: AgentHost, ui: TextualUI) -> None:
|
|
283
|
+
super().__init__()
|
|
284
|
+
self.host = host
|
|
285
|
+
self.ui = ui
|
|
286
|
+
self._turn_task: asyncio.Task[None] | None = None
|
|
287
|
+
self._interrupt_count = 0
|
|
288
|
+
host.on_session_changed = lambda _meta: self.call_later(self.update_status_bar)
|
|
289
|
+
|
|
290
|
+
def compose(self) -> ComposeResult:
|
|
291
|
+
yield Static("", id="status-bar")
|
|
292
|
+
yield RichLog(id="conversation", markup=True, highlight=True, wrap=True, auto_scroll=True)
|
|
293
|
+
yield Label(
|
|
294
|
+
"Ctrl+Enter send · Ctrl+P cmds · Ctrl+O sessions · Ctrl+N new · Ctrl+C cancel",
|
|
295
|
+
id="input-hint",
|
|
296
|
+
)
|
|
297
|
+
yield TextArea(id="composer", language=None, soft_wrap=True)
|
|
298
|
+
yield Footer()
|
|
299
|
+
|
|
300
|
+
def on_mount(self) -> None:
|
|
301
|
+
self.ui.bind_app(self)
|
|
302
|
+
self.update_status_bar()
|
|
303
|
+
self.query_one("#composer", TextArea).focus()
|
|
304
|
+
self.query_one("#conversation", RichLog).write(
|
|
305
|
+
"[dim]Noah Code TUI ready. Type a task or /help.[/dim]"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def update_status_bar(self) -> None:
|
|
309
|
+
meta = self.host.meta
|
|
310
|
+
mode = self.host.agent.mode if self.host._agent else self.host.config.mode
|
|
311
|
+
model = meta.model if meta else self.host.config.model
|
|
312
|
+
sid = meta.session_id[:8] if meta else "?"
|
|
313
|
+
title = ""
|
|
314
|
+
if meta and meta.title and meta.title != "untitled":
|
|
315
|
+
title = f" │ {meta.title[:24]}"
|
|
316
|
+
ws = self.host.workspace.root.name
|
|
317
|
+
busy = "busy" if self.ui.busy else "idle"
|
|
318
|
+
flag = ""
|
|
319
|
+
if getattr(self.host, "_last_turn_shell_bypass", False):
|
|
320
|
+
flag = " │ shell⚠"
|
|
321
|
+
text = f" {mode} │ {model} │ {sid}{title} │ {ws} │ {busy}{flag} "
|
|
322
|
+
with contextlib.suppress(Exception):
|
|
323
|
+
self.query_one("#status-bar", Static).update(text)
|
|
324
|
+
|
|
325
|
+
def _append_event(self, event: HostEvent) -> None:
|
|
326
|
+
log = self.query_one("#conversation", RichLog)
|
|
327
|
+
kind = event.kind
|
|
328
|
+
text = event.text.rstrip()
|
|
329
|
+
if kind == HostEventKind.MESSAGE:
|
|
330
|
+
log.write(text)
|
|
331
|
+
elif kind == HostEventKind.REASONING:
|
|
332
|
+
if self.host.config.ui.show_reasoning:
|
|
333
|
+
log.write(f"[dim]thinking:[/dim] {text}")
|
|
334
|
+
elif kind == HostEventKind.TOOL_START:
|
|
335
|
+
log.write(f"[cyan]→[/cyan] {text}")
|
|
336
|
+
elif kind == HostEventKind.TOOL_FINISH:
|
|
337
|
+
log.write(f"[green]✓[/green] {text}")
|
|
338
|
+
elif kind == HostEventKind.SHELL_CHUNK:
|
|
339
|
+
stream = event.meta.get("stream", "stdout")
|
|
340
|
+
style = "red" if stream == "stderr" else ("dim" if stream == "status" else "white")
|
|
341
|
+
log.write(f"[{style}]{text}[/{style}]")
|
|
342
|
+
elif kind == HostEventKind.ERROR:
|
|
343
|
+
log.write(f"[bold red]error:[/bold red] {text}")
|
|
344
|
+
elif kind == HostEventKind.SUMMARY:
|
|
345
|
+
log.write(f"[blue]summary:[/blue] {text}")
|
|
346
|
+
elif kind == HostEventKind.STATUS:
|
|
347
|
+
log.write(f"[dim]{text}[/dim]")
|
|
348
|
+
elif kind == HostEventKind.STOP:
|
|
349
|
+
log.write(f"[bold]stop:[/bold] {text}")
|
|
350
|
+
else:
|
|
351
|
+
log.write(text)
|
|
352
|
+
self.update_status_bar()
|
|
353
|
+
|
|
354
|
+
@on(HostEventMessage)
|
|
355
|
+
def _on_host_event(self, message: HostEventMessage) -> None:
|
|
356
|
+
self._append_event(message.event)
|
|
357
|
+
|
|
358
|
+
async def request_approval(self, request: ApprovalRequest) -> ApprovalChoice:
|
|
359
|
+
result = await self.push_screen_wait(ApprovalModal(request))
|
|
360
|
+
return result if result is not None else ApprovalChoice.REJECT
|
|
361
|
+
|
|
362
|
+
def action_show_help(self) -> None:
|
|
363
|
+
self.query_one("#conversation", RichLog).write(help_text(self.host._custom_commands))
|
|
364
|
+
|
|
365
|
+
@work(exclusive=True, group="palette")
|
|
366
|
+
async def action_palette(self) -> None:
|
|
367
|
+
cmds = all_command_names(self.host._custom_commands)
|
|
368
|
+
choice = await self.push_screen_wait(CommandPalette(cmds))
|
|
369
|
+
if choice:
|
|
370
|
+
composer = self.query_one("#composer", TextArea)
|
|
371
|
+
composer.text = choice
|
|
372
|
+
composer.focus()
|
|
373
|
+
|
|
374
|
+
@work(exclusive=True, group="sessions")
|
|
375
|
+
async def action_sessions(self) -> None:
|
|
376
|
+
rows = [(s.session_id, f"{s.mode} {s.title}") for s in self.host.list_session_metas()]
|
|
377
|
+
sid = await self.push_screen_wait(SessionPicker(rows))
|
|
378
|
+
if not sid:
|
|
379
|
+
return
|
|
380
|
+
try:
|
|
381
|
+
await self.host.switch_session(sid)
|
|
382
|
+
self.query_one("#conversation", RichLog).write(f"[dim]switched to session {sid}[/dim]")
|
|
383
|
+
self.update_status_bar()
|
|
384
|
+
except Exception as exc: # noqa: BLE001
|
|
385
|
+
self.query_one("#conversation", RichLog).write(f"[bold red]error:[/bold red] {exc}")
|
|
386
|
+
|
|
387
|
+
@work(exclusive=True, group="sessions")
|
|
388
|
+
async def action_new_session(self) -> None:
|
|
389
|
+
meta = await self.host.start_new_session()
|
|
390
|
+
self.query_one("#conversation", RichLog).write(f"[dim]new session {meta.session_id}[/dim]")
|
|
391
|
+
self.update_status_bar()
|
|
392
|
+
|
|
393
|
+
def action_quit_app(self) -> None:
|
|
394
|
+
self.exit()
|
|
395
|
+
|
|
396
|
+
def action_cancel_or_quit(self) -> None:
|
|
397
|
+
if self.ui.busy and self._turn_task and not self._turn_task.done():
|
|
398
|
+
self.host.cancel_active_turn()
|
|
399
|
+
self.ui.set_busy(False)
|
|
400
|
+
self.query_one("#conversation", RichLog).write("[dim]turn cancelled[/dim]")
|
|
401
|
+
self._interrupt_count = 0
|
|
402
|
+
self.update_status_bar()
|
|
403
|
+
return
|
|
404
|
+
self._interrupt_count += 1
|
|
405
|
+
if self._interrupt_count >= 2:
|
|
406
|
+
self.exit()
|
|
407
|
+
else:
|
|
408
|
+
self.query_one("#conversation", RichLog).write("[dim]Ctrl+C again to quit[/dim]")
|
|
409
|
+
|
|
410
|
+
def action_submit(self) -> None:
|
|
411
|
+
composer = self.query_one("#composer", TextArea)
|
|
412
|
+
text = composer.text.strip()
|
|
413
|
+
if not text or self.ui.busy:
|
|
414
|
+
return
|
|
415
|
+
composer.text = ""
|
|
416
|
+
log = self.query_one("#conversation", RichLog)
|
|
417
|
+
log.write(f"[bold reverse] you [/bold reverse] {text}")
|
|
418
|
+
self._interrupt_count = 0
|
|
419
|
+
self._run_turn(text)
|
|
420
|
+
|
|
421
|
+
@work(exclusive=True, group="turn")
|
|
422
|
+
async def _run_turn(self, text: str) -> None:
|
|
423
|
+
self._turn_task = asyncio.current_task()
|
|
424
|
+
try:
|
|
425
|
+
action = await self.host.handle_line(text)
|
|
426
|
+
if action == "exit":
|
|
427
|
+
self.exit()
|
|
428
|
+
except asyncio.CancelledError:
|
|
429
|
+
self.query_one("#conversation", RichLog).write("[dim]turn cancelled[/dim]")
|
|
430
|
+
except Exception as exc: # noqa: BLE001
|
|
431
|
+
self.query_one("#conversation", RichLog).write(f"[bold red]error:[/bold red] {exc}")
|
|
432
|
+
finally:
|
|
433
|
+
self._turn_task = None
|
|
434
|
+
self.update_status_bar()
|
|
435
|
+
self.query_one("#composer", TextArea).focus()
|
noah_code/updates.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Version checks and uv-managed self-updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from packaging.version import InvalidVersion, Version
|
|
19
|
+
|
|
20
|
+
from noah_code import __version__
|
|
21
|
+
|
|
22
|
+
PACKAGE_NAME = "noah-code"
|
|
23
|
+
PYPI_METADATA_URL = "https://pypi.org/pypi/noah-code/json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UpdateError(RuntimeError):
|
|
27
|
+
"""An update check or installation could not be completed."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class UpdateStatus:
|
|
32
|
+
current: str
|
|
33
|
+
latest: str
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def available(self) -> bool:
|
|
37
|
+
try:
|
|
38
|
+
return Version(self.latest) > Version(self.current)
|
|
39
|
+
except InvalidVersion as exc:
|
|
40
|
+
raise UpdateError(f"invalid package version returned by index: {exc}") from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _state_path() -> Path:
|
|
44
|
+
root = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
|
|
45
|
+
return root.expanduser() / "noah-code" / "update.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_state() -> dict[str, object]:
|
|
49
|
+
path = _state_path()
|
|
50
|
+
try:
|
|
51
|
+
value = json.loads(path.read_text())
|
|
52
|
+
except (OSError, json.JSONDecodeError):
|
|
53
|
+
return {}
|
|
54
|
+
return value if isinstance(value, dict) else {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _write_state(state: dict[str, object]) -> None:
|
|
58
|
+
path = _state_path()
|
|
59
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
60
|
+
path.parent.chmod(0o700)
|
|
61
|
+
fd, temporary = tempfile.mkstemp(prefix=".update-", dir=path.parent)
|
|
62
|
+
try:
|
|
63
|
+
with os.fdopen(fd, "w") as stream:
|
|
64
|
+
json.dump(state, stream, sort_keys=True)
|
|
65
|
+
stream.write("\n")
|
|
66
|
+
stream.flush()
|
|
67
|
+
os.fsync(stream.fileno())
|
|
68
|
+
Path(temporary).chmod(0o600)
|
|
69
|
+
os.replace(temporary, path)
|
|
70
|
+
finally:
|
|
71
|
+
with contextlib.suppress(FileNotFoundError):
|
|
72
|
+
Path(temporary).unlink()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def find_uv() -> Path | None:
|
|
76
|
+
configured = os.environ.get("NOAH_CODE_UV")
|
|
77
|
+
candidates = [
|
|
78
|
+
Path(configured).expanduser() if configured else None,
|
|
79
|
+
Path(found) if (found := shutil.which("uv")) else None,
|
|
80
|
+
Path.home() / ".local" / "bin" / "uv",
|
|
81
|
+
]
|
|
82
|
+
for candidate in candidates:
|
|
83
|
+
if candidate and candidate.is_file() and os.access(candidate, os.X_OK):
|
|
84
|
+
return candidate.resolve()
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_uv_tool_install(uv: Path | None = None) -> bool:
|
|
89
|
+
executable = uv or find_uv()
|
|
90
|
+
if executable is None:
|
|
91
|
+
return False
|
|
92
|
+
try:
|
|
93
|
+
result = subprocess.run(
|
|
94
|
+
[str(executable), "tool", "dir"],
|
|
95
|
+
check=True,
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
timeout=10,
|
|
99
|
+
)
|
|
100
|
+
tools_dir = Path(result.stdout.strip()).expanduser().resolve()
|
|
101
|
+
Path(sys.prefix).resolve().relative_to(tools_dir)
|
|
102
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def check_for_update(*, timeout: float = 5.0) -> UpdateStatus:
|
|
108
|
+
request = urllib.request.Request(
|
|
109
|
+
PYPI_METADATA_URL,
|
|
110
|
+
headers={"Accept": "application/json", "User-Agent": f"noah-code/{__version__}"},
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
|
114
|
+
raw = response.read(1_000_001)
|
|
115
|
+
except (OSError, urllib.error.URLError) as exc:
|
|
116
|
+
raise UpdateError(f"could not query PyPI: {exc}") from exc
|
|
117
|
+
if len(raw) > 1_000_000:
|
|
118
|
+
raise UpdateError("PyPI response exceeded 1 MB")
|
|
119
|
+
try:
|
|
120
|
+
payload = json.loads(raw)
|
|
121
|
+
latest = payload["info"]["version"]
|
|
122
|
+
except (json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
123
|
+
raise UpdateError("PyPI returned invalid package metadata") from exc
|
|
124
|
+
if not isinstance(latest, str):
|
|
125
|
+
raise UpdateError("PyPI returned an invalid version")
|
|
126
|
+
status = UpdateStatus(current=__version__, latest=latest)
|
|
127
|
+
_ = status.available # validate both versions before returning
|
|
128
|
+
return status
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def upgrade(*, uv: Path | None = None, timeout: float = 300.0) -> str:
|
|
132
|
+
executable = uv or find_uv()
|
|
133
|
+
if executable is None:
|
|
134
|
+
raise UpdateError("uv was not found; reinstall noah-code with the documented installer")
|
|
135
|
+
if not is_uv_tool_install(executable):
|
|
136
|
+
raise UpdateError(
|
|
137
|
+
"this copy is not a uv tool install; update it with the package manager used "
|
|
138
|
+
"to install it"
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
result = subprocess.run(
|
|
142
|
+
[str(executable), "tool", "upgrade", "--no-build", PACKAGE_NAME],
|
|
143
|
+
check=False,
|
|
144
|
+
capture_output=True,
|
|
145
|
+
text=True,
|
|
146
|
+
timeout=timeout,
|
|
147
|
+
)
|
|
148
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
149
|
+
raise UpdateError(f"could not run uv: {exc}") from exc
|
|
150
|
+
output = "\n".join(part.strip() for part in (result.stdout, result.stderr) if part.strip())
|
|
151
|
+
if result.returncode != 0:
|
|
152
|
+
raise UpdateError(output or f"uv exited with status {result.returncode}")
|
|
153
|
+
return output or "uv completed the update"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def maybe_auto_update(*, interval_hours: int, timeout: float) -> str | None:
|
|
157
|
+
"""Install a newer release at most once per interval for uv tool installs."""
|
|
158
|
+
uv = find_uv()
|
|
159
|
+
if uv is None or not is_uv_tool_install(uv):
|
|
160
|
+
return None
|
|
161
|
+
state = _read_state()
|
|
162
|
+
now = time.time()
|
|
163
|
+
checked_at = state.get("checked_at", 0)
|
|
164
|
+
if isinstance(checked_at, int | float) and now - checked_at < interval_hours * 3600:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
next_state: dict[str, object] = {"checked_at": now, "current": __version__}
|
|
168
|
+
try:
|
|
169
|
+
status = check_for_update(timeout=timeout)
|
|
170
|
+
next_state["latest"] = status.latest
|
|
171
|
+
if not status.available:
|
|
172
|
+
_write_state(next_state)
|
|
173
|
+
return None
|
|
174
|
+
output = upgrade(uv=uv)
|
|
175
|
+
next_state["updated_to"] = status.latest
|
|
176
|
+
_write_state(next_state)
|
|
177
|
+
return (
|
|
178
|
+
f"noah-code {status.current} was updated to {status.latest}; "
|
|
179
|
+
"rerun your command to use the new version\n" + output
|
|
180
|
+
)
|
|
181
|
+
except UpdateError as exc:
|
|
182
|
+
next_state["error"] = str(exc)
|
|
183
|
+
_write_state(next_state)
|
|
184
|
+
return None
|
noah_code/workspace.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Workspace path validation and identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WorkspaceError(ValueError):
|
|
11
|
+
"""Invalid workspace selection."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Workspace:
|
|
16
|
+
"""Canonical workspace root for a session."""
|
|
17
|
+
|
|
18
|
+
root: Path
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def identity(self) -> str:
|
|
22
|
+
"""Stable identity for resume checks."""
|
|
23
|
+
resolved = str(self.root)
|
|
24
|
+
return hashlib.sha256(resolved.encode()).hexdigest()[:16]
|
|
25
|
+
|
|
26
|
+
def resolve(self, path: str | Path) -> Path:
|
|
27
|
+
"""Resolve a path relative to the workspace without escaping."""
|
|
28
|
+
candidate = Path(path)
|
|
29
|
+
if not candidate.is_absolute():
|
|
30
|
+
candidate = self.root / candidate
|
|
31
|
+
resolved = candidate.resolve()
|
|
32
|
+
try:
|
|
33
|
+
resolved.relative_to(self.root)
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
raise WorkspaceError(f"path escapes workspace: {path}") from exc
|
|
36
|
+
return resolved
|
|
37
|
+
|
|
38
|
+
def relpath(self, path: Path) -> str:
|
|
39
|
+
return str(path.resolve().relative_to(self.root))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def open_workspace(path: str | Path | None = None) -> Workspace:
|
|
43
|
+
"""Validate and open a workspace directory."""
|
|
44
|
+
root = Path(path or ".").expanduser().resolve()
|
|
45
|
+
if not root.exists():
|
|
46
|
+
raise WorkspaceError(f"workspace does not exist: {root}")
|
|
47
|
+
if not root.is_dir():
|
|
48
|
+
raise WorkspaceError(f"workspace is not a directory: {root}")
|
|
49
|
+
return Workspace(root=root)
|