terminalpulse 0.1.0__tar.gz
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.
- terminalpulse-0.1.0/.gitignore +4 -0
- terminalpulse-0.1.0/PKG-INFO +9 -0
- terminalpulse-0.1.0/README.md +55 -0
- terminalpulse-0.1.0/pyproject.toml +18 -0
- terminalpulse-0.1.0/terminalpulse/__init__.py +0 -0
- terminalpulse-0.1.0/terminalpulse/cli.py +108 -0
- terminalpulse-0.1.0/terminalpulse/daemon.py +96 -0
- terminalpulse-0.1.0/terminalpulse/events.py +23 -0
- terminalpulse-0.1.0/terminalpulse/graph.py +66 -0
- terminalpulse-0.1.0/terminalpulse/windows_tracker.py +59 -0
- terminalpulse-0.1.0/test.py +0 -0
- terminalpulse-0.1.0/testfile.py +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# TerminalPulse ⚡
|
|
2
|
+
|
|
3
|
+
Short-term memory for AI agents. Captures your coding context in real time — silently.
|
|
4
|
+
|
|
5
|
+
## The Problem
|
|
6
|
+
Every AI tool today requires you to manually copy-paste your error. That takes 30 seconds and breaks your flow.
|
|
7
|
+
|
|
8
|
+
## The Solution
|
|
9
|
+
TerminalPulse watches your terminal, editor, and filesystem in the background. When something goes wrong, just type `pulse fix` — no copy-pasting, no explaining.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
pip install terminalpulse
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
pulse init
|
|
16
|
+
source ~/.bashrc
|
|
17
|
+
pulse start --watch .
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
| Command | What it does |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `pulse init` | Auto-injects shell hook into .bashrc |
|
|
23
|
+
| `pulse start` | Starts the background daemon |
|
|
24
|
+
| `pulse state` | Shows current coding context as JSON |
|
|
25
|
+
| `pulse fix` | Sends hottest error to TerminalMind for auto-fix |
|
|
26
|
+
| `pulse init-windows` | Setup instructions for Windows focus tracking |
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
Three streams feed a time-decaying knowledge graph:
|
|
30
|
+
- **Focus** — which file is open in VS Code
|
|
31
|
+
- **Activity** — which files were just saved
|
|
32
|
+
- **Distress** — which terminal commands just failed
|
|
33
|
+
|
|
34
|
+
Older events decay automatically so the AI always gets current context.
|
|
35
|
+
|
|
36
|
+
## Test Results
|
|
37
|
+
| Test | Error Type | Result |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| Test 1 | ZeroDivisionError | Fixed and verified |
|
|
40
|
+
| Test 2 | ModuleNotFoundError | Captured (fix depends on TerminalMind version) |
|
|
41
|
+
| Test 3 | SyntaxError | Fixed and verified |
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
44
|
+
- Python 3.10+
|
|
45
|
+
- WSL Ubuntu (for Linux daemon)
|
|
46
|
+
- terminalmind (for pulse fix command)
|
|
47
|
+
|
|
48
|
+
## Architecture
|
|
49
|
+
```
|
|
50
|
+
VS Code (Windows) → HTTP:7077 → pulsed daemon → ~/.devpulse_state.json
|
|
51
|
+
Terminal errors → Unix socket → pulsed daemon → ~/.devpulse_state.json
|
|
52
|
+
File saves → watchdog → pulsed daemon → ~/.devpulse_state.json
|
|
53
|
+
↓
|
|
54
|
+
pulse fix → TerminalMind → Auto-fix
|
|
55
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "terminalpulse"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.10"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"typer>=0.12",
|
|
7
|
+
"rich>=13.7",
|
|
8
|
+
"watchdog>=4.0",
|
|
9
|
+
"networkx>=3.2",
|
|
10
|
+
"pydantic>=2.6",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
pulse = "terminalpulse.cli:app"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import os, sys, json, subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import typer, asyncio
|
|
4
|
+
from rich import print
|
|
5
|
+
from . import daemon as _daemon
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="TerminalPulse — short-term memory for AI agents")
|
|
8
|
+
STATE_FILE = Path.home() / ".devpulse_state.json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.command()
|
|
12
|
+
def start(watch: str = typer.Option(".", help="Directory to watch")):
|
|
13
|
+
"""Run the pulse daemon in the foreground."""
|
|
14
|
+
asyncio.run(_daemon.main(os.path.abspath(watch)))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command()
|
|
18
|
+
def state():
|
|
19
|
+
"""Print the current hottest context."""
|
|
20
|
+
if not STATE_FILE.exists():
|
|
21
|
+
print("[yellow]No state yet. Is the daemon running?[/]")
|
|
22
|
+
raise typer.Exit(1)
|
|
23
|
+
print(json.loads(STATE_FILE.read_text()))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def fix():
|
|
28
|
+
"""Read hottest error and active file, send to TerminalMind to fix."""
|
|
29
|
+
if not STATE_FILE.exists():
|
|
30
|
+
print("[yellow]No state yet. Is the daemon running?[/]")
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
|
|
33
|
+
state_data = json.loads(STATE_FILE.read_text())
|
|
34
|
+
hottest = state_data.get("hottest", [])
|
|
35
|
+
|
|
36
|
+
error = next((e for e in hottest if e["type"] == "command_failed"), None)
|
|
37
|
+
focus = next((e for e in hottest if e["type"] == "focus_changed"), None)
|
|
38
|
+
|
|
39
|
+
if not error:
|
|
40
|
+
print("[yellow]No recent errors found in pulse state.[/]")
|
|
41
|
+
raise typer.Exit(0)
|
|
42
|
+
|
|
43
|
+
cmd = error.get("cmd", "unknown command")
|
|
44
|
+
exit_code = error.get("exit_code", 1)
|
|
45
|
+
active_file = focus.get("path", "unknown file") if focus else "unknown file"
|
|
46
|
+
stderr = error.get("stderr_tail", None)
|
|
47
|
+
|
|
48
|
+
if stderr:
|
|
49
|
+
context = f"Command: `{cmd}` failed with exit code {exit_code}.\nActive file: {active_file}.\nError output:\n{stderr.replace('|', chr(10))}"
|
|
50
|
+
print(f"[cyan]Sending to TerminalMind with full error context[/]")
|
|
51
|
+
subprocess.run(["tmind", "ask", context])
|
|
52
|
+
else:
|
|
53
|
+
context = f"Command failed: `{cmd}` (exit code {exit_code}). Active file: {active_file}."
|
|
54
|
+
print(f"[cyan]Sending to TerminalMind:[/] {context}")
|
|
55
|
+
subprocess.run(["tmind", "heal", cmd], cwd=error.get("cwd", "."))
|
|
56
|
+
|
|
57
|
+
@app.command()
|
|
58
|
+
def init():
|
|
59
|
+
"""Auto-setup: injects shell hook into .bashrc so pulse works automatically."""
|
|
60
|
+
bashrc = Path.home() / ".bashrc"
|
|
61
|
+
|
|
62
|
+
hook = '''
|
|
63
|
+
# TerminalPulse hook
|
|
64
|
+
_pulse_sock="$HOME/.terminalpulse.sock"
|
|
65
|
+
_pulse_last_cmd=""
|
|
66
|
+
_pulse_preexec() {
|
|
67
|
+
[[ "$BASH_COMMAND" == _pulse_* ]] && return
|
|
68
|
+
_pulse_last_cmd="$BASH_COMMAND"
|
|
69
|
+
}
|
|
70
|
+
trap '_pulse_preexec' DEBUG
|
|
71
|
+
_pulse_precmd() {
|
|
72
|
+
local code=$?
|
|
73
|
+
if [ $code -ne 0 ] && [ -n "$_pulse_last_cmd" ] && [ -S "$_pulse_sock" ]; then
|
|
74
|
+
printf \'{"type":"command_failed","cmd":"%s","exit_code":%d,"cwd":"%s"}\\n\' \\
|
|
75
|
+
"$_pulse_last_cmd" "$code" "$PWD" | nc -U "$_pulse_sock" 2>/dev/null
|
|
76
|
+
fi
|
|
77
|
+
_pulse_last_cmd=""
|
|
78
|
+
}
|
|
79
|
+
PROMPT_COMMAND="_pulse_precmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
|
|
80
|
+
'''
|
|
81
|
+
|
|
82
|
+
content = bashrc.read_text() if bashrc.exists() else ""
|
|
83
|
+
if "TerminalPulse hook" in content:
|
|
84
|
+
print("[yellow]TerminalPulse hook already exists in .bashrc — skipping.[/]")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
with open(bashrc, "a") as f:
|
|
88
|
+
f.write(hook)
|
|
89
|
+
|
|
90
|
+
print("[green]TerminalPulse hook added to ~/.bashrc[/]")
|
|
91
|
+
print("[cyan]Run: source ~/.bashrc to activate[/]")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
def init_windows():
|
|
96
|
+
"""Print instructions to set up the Windows focus tracker."""
|
|
97
|
+
print("[cyan]Windows Setup Instructions:[/]")
|
|
98
|
+
print("")
|
|
99
|
+
print("1. Open Windows PowerShell")
|
|
100
|
+
print("2. Run this command:")
|
|
101
|
+
print(f"[green] python {Path.home() / 'terminalpulse/terminalpulse/windows_tracker.py'}[/]")
|
|
102
|
+
print("")
|
|
103
|
+
print("3. Keep PowerShell open while coding.")
|
|
104
|
+
print("[yellow]Tip: Add it to Windows Task Scheduler to auto-start on login.[/]")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
app()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio, json, os, signal
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from watchdog.observers import Observer
|
|
5
|
+
from watchdog.events import FileSystemEventHandler
|
|
6
|
+
from .events import Event, EventType
|
|
7
|
+
from .graph import PulseGraph
|
|
8
|
+
|
|
9
|
+
SOCK_PATH = Path.home() / ".terminalpulse.sock"
|
|
10
|
+
WATCH_EXTS = {".py", ".js", ".ts", ".tsx", ".rs", ".go", ".md", ".toml", ".json"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FsHandler(FileSystemEventHandler):
|
|
14
|
+
def __init__(self, queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
|
|
15
|
+
self.queue = queue
|
|
16
|
+
self.loop = loop
|
|
17
|
+
|
|
18
|
+
def on_modified(self, event):
|
|
19
|
+
if event.is_directory: return
|
|
20
|
+
p = Path(event.src_path)
|
|
21
|
+
if p.suffix not in WATCH_EXTS: return
|
|
22
|
+
ev = Event(type=EventType.FILE_SAVED, path=str(p), cwd=os.getcwd())
|
|
23
|
+
self.loop.call_soon_threadsafe(self.queue.put_nowait, ev)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def handle_shell_client(reader, writer, queue: asyncio.Queue):
|
|
27
|
+
data = await reader.readline()
|
|
28
|
+
try:
|
|
29
|
+
payload = json.loads(data)
|
|
30
|
+
ev = Event(**payload)
|
|
31
|
+
await queue.put(ev)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
print(f"bad payload: {e}")
|
|
34
|
+
writer.close()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def handle_http_client(reader, writer, queue: asyncio.Queue):
|
|
38
|
+
data = b""
|
|
39
|
+
while True:
|
|
40
|
+
chunk = await reader.read(1024)
|
|
41
|
+
if not chunk:
|
|
42
|
+
break
|
|
43
|
+
data += chunk
|
|
44
|
+
if b"\r\n\r\n" in data:
|
|
45
|
+
break
|
|
46
|
+
try:
|
|
47
|
+
body = data.split(b"\r\n\r\n", 1)[1]
|
|
48
|
+
payload = json.loads(body)
|
|
49
|
+
ev = Event(**payload)
|
|
50
|
+
await queue.put(ev)
|
|
51
|
+
writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
|
|
52
|
+
except Exception as e:
|
|
53
|
+
print(f"bad http payload: {e}")
|
|
54
|
+
writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 2\r\n\r\nER")
|
|
55
|
+
await writer.drain()
|
|
56
|
+
writer.close()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def consumer(queue: asyncio.Queue, graph: PulseGraph):
|
|
60
|
+
while True:
|
|
61
|
+
ev = await queue.get()
|
|
62
|
+
graph.ingest(ev)
|
|
63
|
+
print(f"⚡ {ev.type.value} {ev.path or ev.cmd or ''}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def main(watch_dir: str):
|
|
67
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
68
|
+
graph = PulseGraph()
|
|
69
|
+
loop = asyncio.get_running_loop()
|
|
70
|
+
|
|
71
|
+
obs = Observer()
|
|
72
|
+
obs.schedule(FsHandler(queue, loop), watch_dir, recursive=True)
|
|
73
|
+
obs.start()
|
|
74
|
+
|
|
75
|
+
if SOCK_PATH.exists(): SOCK_PATH.unlink()
|
|
76
|
+
server = await asyncio.start_unix_server(
|
|
77
|
+
lambda r, w: handle_shell_client(r, w, queue), path=str(SOCK_PATH)
|
|
78
|
+
)
|
|
79
|
+
os.chmod(SOCK_PATH, 0o600)
|
|
80
|
+
|
|
81
|
+
http_server = await asyncio.start_server(
|
|
82
|
+
lambda r, w: handle_http_client(r, w, queue),
|
|
83
|
+
"0.0.0.0", 7077
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
print(f"🧠 pulsed listening on {SOCK_PATH}, watching {watch_dir}")
|
|
87
|
+
print(f"🌐 HTTP endpoint on port 7077 for Windows bridge")
|
|
88
|
+
try:
|
|
89
|
+
await asyncio.gather(
|
|
90
|
+
server.serve_forever(),
|
|
91
|
+
http_server.serve_forever(),
|
|
92
|
+
consumer(queue, graph)
|
|
93
|
+
)
|
|
94
|
+
finally:
|
|
95
|
+
obs.stop(); obs.join()
|
|
96
|
+
if SOCK_PATH.exists(): SOCK_PATH.unlink()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import time
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EventType(str, Enum):
|
|
9
|
+
FILE_SAVED = "file_saved"
|
|
10
|
+
COMMAND_FAILED = "command_failed"
|
|
11
|
+
COMMAND_OK = "command_ok"
|
|
12
|
+
FOCUS_CHANGED = "focus_changed"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Event(BaseModel):
|
|
16
|
+
type: EventType
|
|
17
|
+
ts: float = Field(default_factory=time.time)
|
|
18
|
+
cwd: Optional[str] = None
|
|
19
|
+
# type-specific:
|
|
20
|
+
path: Optional[str] = None # FILE_SAVED, FOCUS_CHANGED
|
|
21
|
+
cmd: Optional[str] = None # COMMAND_*
|
|
22
|
+
exit_code: Optional[int] = None # COMMAND_*
|
|
23
|
+
stderr_tail: Optional[str] = None # COMMAND_FAILED
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json, math, time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import networkx as nx
|
|
6
|
+
from .events import Event, EventType
|
|
7
|
+
|
|
8
|
+
STATE_FILE = Path.home() / ".devpulse_state.json"
|
|
9
|
+
LAMBDA = 0.01 # decay rate (per second). ~0.5 weight after ~70s
|
|
10
|
+
PRUNE_BELOW = 0.01 # drop nodes whose weight falls below this
|
|
11
|
+
SEVERITY = {
|
|
12
|
+
EventType.COMMAND_FAILED: 1.0,
|
|
13
|
+
EventType.FILE_SAVED: 0.3,
|
|
14
|
+
EventType.FOCUS_CHANGED: 0.1,
|
|
15
|
+
EventType.COMMAND_OK: 0.05,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PulseGraph:
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.g = nx.DiGraph()
|
|
22
|
+
self._counter = 0
|
|
23
|
+
self._focus_node: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
def _weight(self, ts: float) -> float:
|
|
26
|
+
return math.exp(-LAMBDA * (time.time() - ts))
|
|
27
|
+
|
|
28
|
+
def ingest(self, ev: Event) -> None:
|
|
29
|
+
nid = f"{ev.type.value}:{self._counter}"
|
|
30
|
+
self._counter += 1
|
|
31
|
+
self.g.add_node(nid, event=ev.model_dump(), severity=SEVERITY[ev.type])
|
|
32
|
+
|
|
33
|
+
# link errors to current focus
|
|
34
|
+
if ev.type == EventType.COMMAND_FAILED and self._focus_node:
|
|
35
|
+
self.g.add_edge(nid, self._focus_node, kind="occurred_while_editing")
|
|
36
|
+
|
|
37
|
+
if ev.type == EventType.FOCUS_CHANGED:
|
|
38
|
+
self._focus_node = nid
|
|
39
|
+
|
|
40
|
+
self._prune()
|
|
41
|
+
self.export()
|
|
42
|
+
|
|
43
|
+
def _prune(self) -> None:
|
|
44
|
+
dead = [n for n, d in self.g.nodes(data=True)
|
|
45
|
+
if self._weight(d["event"]["ts"]) < PRUNE_BELOW]
|
|
46
|
+
self.g.remove_nodes_from(dead)
|
|
47
|
+
if self._focus_node not in self.g:
|
|
48
|
+
self._focus_node = None
|
|
49
|
+
|
|
50
|
+
def hottest(self, k: int = 5) -> list[dict]:
|
|
51
|
+
scored = []
|
|
52
|
+
for n, d in self.g.nodes(data=True):
|
|
53
|
+
heat = self._weight(d["event"]["ts"]) * d["severity"]
|
|
54
|
+
scored.append((heat, d["event"]))
|
|
55
|
+
scored.sort(key=lambda x: -x[0])
|
|
56
|
+
return [{"heat": round(h, 4), **e} for h, e in scored[:k]]
|
|
57
|
+
|
|
58
|
+
def export(self) -> None:
|
|
59
|
+
state = {
|
|
60
|
+
"generated_at": time.time(),
|
|
61
|
+
"hottest": self.hottest(5),
|
|
62
|
+
"node_count": self.g.number_of_nodes(),
|
|
63
|
+
}
|
|
64
|
+
tmp = STATE_FILE.with_suffix(".tmp")
|
|
65
|
+
tmp.write_text(json.dumps(state, indent=2))
|
|
66
|
+
tmp.replace(STATE_FILE) # atomic write
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import time
|
|
3
|
+
import json
|
|
4
|
+
import urllib.request
|
|
5
|
+
import ctypes
|
|
6
|
+
import ctypes.wintypes
|
|
7
|
+
|
|
8
|
+
PULSE_PORT = 7077
|
|
9
|
+
POLL_INTERVAL = 3
|
|
10
|
+
EDITORS = {"Visual Studio Code", "Cursor", "VSCodium"}
|
|
11
|
+
|
|
12
|
+
def get_active_window_title() -> str:
|
|
13
|
+
hwnd = ctypes.windll.user32.GetForegroundWindow()
|
|
14
|
+
length = ctypes.windll.user32.GetWindowTextLengthW(hwnd)
|
|
15
|
+
buf = ctypes.create_unicode_buffer(length + 1)
|
|
16
|
+
ctypes.windll.user32.GetWindowTextW(hwnd, buf, length + 1)
|
|
17
|
+
return buf.value
|
|
18
|
+
|
|
19
|
+
IGNORE = {"Visual Studio Code", "Cursor", "Welcome", "New Tab", "Settings", ""}
|
|
20
|
+
|
|
21
|
+
def extract_file(title: str) -> str | None:
|
|
22
|
+
for editor in EDITORS:
|
|
23
|
+
if editor in title:
|
|
24
|
+
parts = title.split(" - ")
|
|
25
|
+
if parts:
|
|
26
|
+
name = parts[0].strip().lstrip("●").strip()
|
|
27
|
+
if name and name not in IGNORE:
|
|
28
|
+
return name
|
|
29
|
+
return None
|
|
30
|
+
def send_focus(filename: str, port: int = PULSE_PORT):
|
|
31
|
+
payload = json.dumps({
|
|
32
|
+
"type": "focus_changed",
|
|
33
|
+
"path": filename,
|
|
34
|
+
"cwd": ""
|
|
35
|
+
}).encode()
|
|
36
|
+
try:
|
|
37
|
+
req = urllib.request.Request(
|
|
38
|
+
f"http://localhost:{port}",
|
|
39
|
+
data=payload,
|
|
40
|
+
headers={"Content-Type": "application/json"}
|
|
41
|
+
)
|
|
42
|
+
urllib.request.urlopen(req, timeout=2)
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
def run():
|
|
47
|
+
print(f"TerminalPulse Windows tracker running (port {PULSE_PORT})")
|
|
48
|
+
last = None
|
|
49
|
+
while True:
|
|
50
|
+
title = get_active_window_title()
|
|
51
|
+
f = extract_file(title)
|
|
52
|
+
if f and f != last:
|
|
53
|
+
print(f"Focus: {f}")
|
|
54
|
+
send_focus(f)
|
|
55
|
+
last = f
|
|
56
|
+
time.sleep(POLL_INTERVAL)
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
run()
|
|
File without changes
|
|
File without changes
|