core: confirmed keymap, tmux injection, session targeting
keys.py is the single source of truth for claude code's prompt keystrokes, confirmed empirically against a live v2.1.191 session: bare digits select- and-confirm immediately (no trailing enter). inject.py only ever calls tmux send-keys (PTY injection, never OS input). target.py resolves the active session from ~/.claude-active with a has-session guard and a single session_name() mapping (claude-<name>) shared with the cc kit. Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
parent
c61eb85748
commit
732bad4c8d
84
src/claudedo/inject.py
Normal file
84
src/claudedo/inject.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""inject keystrokes into a tmux session via ``tmux send-keys``.
|
||||
|
||||
this is the ONLY mechanism by which claudedo affects claude code — PTY injection,
|
||||
never OS-level keyboard input. it works regardless of which window is focused and
|
||||
never touches Windows input or a game/anticheat's view (it is text into a linux
|
||||
pseudo-terminal). do not replace this with OS keystroke injection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from . import keys, target
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InjectError(Exception):
|
||||
"""raised when a tmux send-keys call fails."""
|
||||
|
||||
|
||||
def _send_keys(session: str, args: list[str], literal: bool) -> None:
|
||||
cmd = ["tmux", "send-keys", "-t", session]
|
||||
if literal:
|
||||
cmd.append("-l")
|
||||
cmd.extend(args)
|
||||
result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
if result.returncode != 0:
|
||||
err = result.stderr.decode("utf-8", "replace").strip()
|
||||
raise InjectError(f"tmux send-keys failed: {err}")
|
||||
|
||||
|
||||
def send_named(session: str, key_tokens: list[str]) -> None:
|
||||
"""send a sequence of named tmux keys (e.g. ['1'] or ['Down', 'Enter'])."""
|
||||
if not target.session_exists(session):
|
||||
log.warning("refusing to inject — session %r does not exist", session)
|
||||
return
|
||||
for token in key_tokens:
|
||||
_send_keys(session, [token], literal=False)
|
||||
log.info("injected keys %s -> %s", key_tokens, session)
|
||||
|
||||
|
||||
def send_literal(session: str, text: str) -> None:
|
||||
"""insert literal text into the input box without submitting (``type``)."""
|
||||
if not text:
|
||||
return
|
||||
if not target.session_exists(session):
|
||||
log.warning("refusing to inject — session %r does not exist", session)
|
||||
return
|
||||
_send_keys(session, [text], literal=True)
|
||||
log.info("injected literal text (%d chars) -> %s", len(text), session)
|
||||
|
||||
|
||||
def perform(session: str, action) -> bool:
|
||||
"""resolve a grammar.Action to keystrokes and inject them. returns acted?.
|
||||
|
||||
``switch`` and ``mode`` are handled by the daemon (they change daemon state, not
|
||||
the claude session), so they are ignored here.
|
||||
"""
|
||||
name = action.name
|
||||
if name == "yes":
|
||||
send_named(session, keys.YES)
|
||||
elif name == "no":
|
||||
send_named(session, keys.NO)
|
||||
elif name == "approve":
|
||||
send_named(session, keys.APPROVE)
|
||||
elif name == "deny":
|
||||
send_named(session, keys.DENY)
|
||||
elif name == "submit":
|
||||
send_named(session, keys.SUBMIT)
|
||||
elif name == "cancel":
|
||||
send_named(session, keys.CANCEL)
|
||||
elif name == "select":
|
||||
seq = keys.SELECT_BY_INDEX.get(int(action.arg))
|
||||
if seq is None:
|
||||
log.warning("no keymap for select index %r", action.arg)
|
||||
return False
|
||||
send_named(session, seq)
|
||||
elif name == "type":
|
||||
send_literal(session, str(action.arg))
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
50
src/claudedo/keys.py
Normal file
50
src/claudedo/keys.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""the claude code prompt keymap — single source of truth.
|
||||
|
||||
these keystrokes were confirmed empirically against a live ``claude`` v2.1.191
|
||||
session in a throwaway tmux session (capture-pane observation of benign prompts:
|
||||
the folder-trust prompt and a bash-command permission prompt). do not guess or
|
||||
assume — if claude code changes its prompt ui, re-confirm against a live session
|
||||
and update here.
|
||||
|
||||
confirmed behaviour (claude code v2.1.191):
|
||||
- numbered select menus (trust prompt, permission prompt): pressing the bare
|
||||
digit selects AND confirms IMMEDIATELY. NO trailing enter. (sending an extra
|
||||
enter would leak into the next prompt or the input box.)
|
||||
- arrow keys (Up/Down) move the highlight WITHOUT acting; Enter then confirms —
|
||||
available as a robust alternative, modelled as a sequence.
|
||||
- the permission prompt is "1. Yes / 2. Yes, and don't ask again / 3. No".
|
||||
- Escape backs out of / cancels a prompt ("Esc to cancel" footer).
|
||||
- the main input box: literal text is inserted via ``send-keys -l`` without
|
||||
submitting; a bare Enter submits.
|
||||
|
||||
each value is a list of tmux key tokens to send in order. a single-element list
|
||||
is a single keypress. ``type`` literal text is handled separately by inject.py
|
||||
via ``send-keys -l`` and is not part of this keymap.
|
||||
"""
|
||||
|
||||
YES = ["1"]
|
||||
NO = ["3"]
|
||||
|
||||
SELECT_1 = ["1"]
|
||||
SELECT_2 = ["2"]
|
||||
SELECT_3 = ["3"]
|
||||
SELECT_4 = ["4"]
|
||||
|
||||
APPROVE = ["1"]
|
||||
APPROVE_ALWAYS = ["2"]
|
||||
DENY = ["3"]
|
||||
|
||||
SUBMIT = ["Enter"]
|
||||
CANCEL = ["Escape"]
|
||||
|
||||
SELECT_BY_INDEX = {
|
||||
1: SELECT_1,
|
||||
2: SELECT_2,
|
||||
3: SELECT_3,
|
||||
4: SELECT_4,
|
||||
}
|
||||
|
||||
SELECT_1_ARROW = ["Up", "Up", "Up", "Enter"]
|
||||
SELECT_2_ARROW = ["Up", "Up", "Enter"]
|
||||
SELECT_3_ARROW = ["Up", "Enter"]
|
||||
SELECT_4_ARROW = ["Enter"]
|
||||
88
src/claudedo/target.py
Normal file
88
src/claudedo/target.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""resolve the active claude code tmux session from ~/.claude-active."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ACTIVE_FILE = Path.home() / ".claude-active"
|
||||
|
||||
SESSION_PREFIX = "claude-"
|
||||
|
||||
|
||||
def session_name(name: str) -> str:
|
||||
"""map a project short-name to its tmux session name (``claude-<name>``).
|
||||
|
||||
single source of truth for the name->session mapping. the shell cc kit
|
||||
(~/.config/claudedo/cc.sh) mirrors this exactly, so ``cc libs`` and the voice
|
||||
commands ``switch libs`` / ``target libs`` all resolve to ``claude-libs``. an
|
||||
already-prefixed name is returned unchanged so callers can pass either form.
|
||||
"""
|
||||
name = name.strip()
|
||||
return name if name.startswith(SESSION_PREFIX) else f"{SESSION_PREFIX}{name}"
|
||||
|
||||
|
||||
def read_active() -> str | None:
|
||||
"""return the target session name from ~/.claude-active, or None if unset."""
|
||||
try:
|
||||
name = ACTIVE_FILE.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
log.warning("could not read %s: %s", ACTIVE_FILE, exc)
|
||||
return None
|
||||
return name or None
|
||||
|
||||
|
||||
def write_active(name: str) -> None:
|
||||
"""overwrite ~/.claude-active with a session name (used by ``switch``)."""
|
||||
ACTIVE_FILE.write_text(name + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def set_target(name: str) -> str:
|
||||
"""map a project short-name via session_name() and persist it. returns the
|
||||
resolved session name."""
|
||||
session = session_name(name)
|
||||
write_active(session)
|
||||
return session
|
||||
|
||||
|
||||
def session_exists(name: str) -> bool:
|
||||
"""true if a tmux session with this name currently exists."""
|
||||
if not name:
|
||||
return False
|
||||
result = subprocess.run(
|
||||
["tmux", "has-session", "-t", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def resolve_target() -> str | None:
|
||||
"""return the active session name only if it exists; else log and return None.
|
||||
|
||||
never guesses a target: on a missing/empty ~/.claude-active or a stale session
|
||||
name, this logs a clear warning and returns None so the caller injects nothing.
|
||||
"""
|
||||
name = read_active()
|
||||
if not name:
|
||||
log.warning("no active session set (%s missing/empty) — run `cc` to attach", ACTIVE_FILE)
|
||||
return None
|
||||
if not session_exists(name):
|
||||
log.warning("target session %r no longer exists — skipping injection", name)
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
# TODO: most-recently-active targeting (preferred over attached). today the target
|
||||
# is "the project most recently ATTACHED to" (the cc kit writes ~/.claude-active on
|
||||
# attach). upgrade to "the session claude most recently asked a question / produced
|
||||
# output in" via tmux session_activity timestamps:
|
||||
# tmux list-sessions -F '#{session_name} #{session_activity}'
|
||||
# pick the highest-activity claude-* session; or scrape panes
|
||||
# (tmux capture-pane -p -t <s>) for a waiting-prompt UI and target the session whose
|
||||
# pane currently shows one.
|
||||
Loading…
Reference in New Issue
Block a user