Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: git changes, remote systems, replay, background terminals, agen…
…t notes and task manager

- git: ±N chip on projects, Changes tab with per-file line counts and diffs
- remotes: saved ssh hosts (terminal) and rdp desktops (mstsc/xfreerdp/remmina), validated, no passwords
- replay last command (Ctrl+Alt+P, pane button, palette)
- send terminal to background (Ctrl+Alt+W, pane button); close offers background while a command runs
- sd notes for the scratchpad, live in open pages
- sd top / ps / kill for the task manager, only inside shelldeck terminals
- skill teaches notes and task manager
  • Loading branch information
codejunction authored and claude committed Oct 5, 2026
commit 02b4be7fcc8a4fd09b81181cf903475652ca678e
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to shelldeck are listed here. The format follows [Keep a Cha

## [Unreleased]

### Added

- Git changes at a glance: a `±N` chip on git projects in the sidebar, and a *Changes* tab in the git dialog with branch, ahead/behind, each changed file's added/removed lines and its diff.
- Remote systems (*More › Remote systems*): save SSH hosts (open as shelldeck terminals) and RDP desktops (mstsc on Windows, xfreerdp or Remmina on Linux). No passwords are stored.
- Replay the last command: `Ctrl+Alt+P`, the pane's replay button or the palette.
- Background terminals: the pane's eye button and `Ctrl+Alt+W` keep a terminal running off screen (marked *running* in the sidebar while busy); closing a terminal with a running command offers to keep it in the background.
- `sd notes list|add|append|show|replace|delete` for the Scratchpad (open pages update at once), and `sd top`, `sd ps`, `sd kill` for the Task manager (only processes inside shelldeck terminals). The agent skill teaches both.

## [0.0.8] - 2026-10-05

### Fixed
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ uv tool install --force -e . # global sd/shelldeck from this checkout (th
- **File editor.** `/api/fs/file` GET/PUT (2 MB cap, NUL = binary, non-UTF-8 is read-only, CRLF/BOM round-trip, `mtime` as a `st_mtime_ns` string and 409 `changed_on_disk` unless `force`), `/api/fs/raw` (raster images only: SVG or HTML served on this origin would run script), `/api/fs/show` (broadcasts `open_file` for `sd edit/view`; the CLI falls back to `/?file=` when no browser is listening). Relative paths start at the session's cwd (`_abs`).
- **Scratchpad.** `scratch` table (`id`, `body`, times; the title is the first line), `/api/scratch` CRUD, `static/scratch.js` (autosave 500ms, flushed when switching notes).
- `shelldeck/auth.py`: password, setup code, browser sessions (5s cache, idle expiry, throttled `last_seen` writes), CLI token, login rate limit, `reset()`. `scheduler.py` / `runner.py`: APScheduler cron jobs and reminders; jobs run via `subprocess.run` with the default shell.
- `shelldeck/gitgraph.py`: git log parsing + lane layout for the sidebar git popup (`static/gitgraph.js`), and `checkout` (refs starting with `-` refused). `has_git` flags projects in `/api/projects`.
- `shelldeck/gitgraph.py`: git log parsing + lane layout for the sidebar git popup (`static/gitgraph.js`), and `checkout` (refs starting with `-` refused). `has_git` flags projects in `/api/projects`. `changes()` (porcelain v1 `-z` + numstat against HEAD, untracked counted from the file) and `diff()` (only paths `changes()` reports) back the dialog's *Changes* tab; `/api/projects` adds `git_changes` from `_git_count` (cached `GIT_COUNT_S`) for the sidebar `±N` chip.
- `shelldeck/remotes.py` + `static/remotes.js`: saved remote systems (`remotes` table). `validate()` is the only way values get in (host/user regexes, no leading `-`, port range, key path without shell metacharacters), and connect re-validates stored rows. SSH opens a normal terminal and `_start_agent` types `ssh_line()` after the first prompt; RDP runs `rdp_argv()` (mstsc on Windows, xfreerdp3/xfreerdp/remmina elsewhere) detached, host only (`_host`). No passwords are stored.
- **Background terminals.** `hidePane` (pane eye button, `Ctrl+Alt+W`) only removes the leaf; the PTY and `Term` keep running and the sidebar row shows `bg-chip` while `t.running`. `closeTerminal` (pane ×, sidebar ×, `Ctrl+Alt+Q`) offers *Keep in background* when a command runs. `replayLast` (`Ctrl+Alt+P`) re-sends `t.cmd`, refused while running or when an agent owns the pane.
- **Agents and the UI.** `sd notes` (scratchpad; CLI writes broadcast `{"type":"scratch"}` so an open Scratchpad reloads unless mid-edit; `_text_args` in `_main` passes markdown starting with `-` after `--`), `sd top` (`/api/stats`), `sd ps` / `sd kill` (`/api/sessions/{id}/processes`, `stats.processes` / `stats.end`: only descendants of that terminal's shell, terminate then kill after 3s, zombies count as ended; the CLI also refuses its own ancestors). All are in the skill text.
- `shelldeck/addons/fast_context.py`: vendored single-file LLM-free code search behind `sd search` (own index in `~/.cache/fastcontext`, `FC_CACHE_DIR` overrides). Import it lazily; keep edits to it minimal.
- `shelldeck/cli.py`: Typer CLI.
- The `_main` shim turns `sd <folder>` into `sd open <folder>`.
Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,15 @@ sd ~/code/api # add a folder as a project and open a terminal in it
- **Tiled layout.** Split right or down, drag titles onto edges to split or onto a pane to swap, drag gutters to resize, double-click a title to maximize.
- **Free layout.** Every terminal becomes a floating window on a scrolling canvas. **Tile all** puts them back in a grid.
- **Nothing gets lost.** Closing the tab keeps terminals running, and reopening replays their output. After a restart or reboot, each terminal reopens in its last folder with its history above a "restored" marker.
- **Replay the last command** with `Ctrl+Alt+P` or the pane's replay button (refused while a command runs, or when an AI agent owns the terminal).
- **Run in the background.** The pane's eye button (`Ctrl+Alt+W`) takes a terminal off the screen while it keeps running; it stays in the sidebar, marked *running* while a command is busy, and a click brings it back. Closing a terminal with a running command asks whether to keep it in the background instead.
- **Shell integration** for pwsh, PowerShell, cmd, bash, zsh and fish. Each pane's title shows the last command (red if it failed), `Ctrl+Shift+Up`/`Down` jumps between prompts, and your own prompt (oh-my-posh, starship, …) still loads.

### Projects first

- **Projects sidebar.** Add folders with the built-in folder browser, then drag to reorder. Each project gets its own color on its sidebar dot, pane border and a subtle background tint.
- **Git graph.** A branch icon on every git project opens its full commit graph, with branches, merges and tags drawn as colored lanes. Click a branch or tag, or right-click a commit, to check it out.
- **Git changes at a glance.** A git project with uncommitted work shows a `±N` chip in the sidebar. Click it (or *Git changes* in the palette) to see the branch, ahead/behind, and every changed file with its added/removed lines; click a file for its diff.
- **Git graph.** The branch icon on every git project also opens its full commit graph, with branches, merges and tags drawn as colored lanes. Click a branch or tag, or right-click a commit, to check it out.
- **Clickable paths.** `src/app.py:12:5`, `C:\x\y.ts(3,4)` and Python tracebacks become links that open in VS Code at that line, or in the built-in editor.
- **Built-in editor and viewer.** `sd edit FILE` and `sd view FILE` open a file in shelldeck: line numbers, `Ctrl+S` to save (CRLF and BOM kept, and it won't overwrite a file that changed on disk without asking), rendered markdown and images. *Open file…* in the command palette does the same.
- **Open in editor.** A project's menu opens its folder in VS Code (or the file manager).
Expand All @@ -84,7 +87,8 @@ sd ~/code/api # add a folder as a project and open a terminal in it
- **Scheduler** for cron jobs that run in a project folder, with run history and logs.
- **Task board** with due dates and reminder alarms.
- **Task manager** with live CPU, RAM and GPU (NVIDIA) usage for the machine and for each terminal's process tree.
- **Scratchpad** for quick markdown notes that belong to no project, with a preview, saved as you type.
- **Scratchpad** for quick markdown notes that belong to no project, with a preview, saved as you type. Agents (and scripts) add to it with `sd notes`.
- **Remote systems.** Save Linux servers (SSH) and Windows machines (RDP) under *More › Remote systems*. *Connect* opens an SSH host as a shelldeck terminal (exit ssh and you're back in a local shell), or a desktop in `mstsc` on Windows (xfreerdp or Remmina on Linux) on the host machine. Passwords are never stored: ssh asks in the terminal (keys and ssh-agent work), the RDP client asks in its window.

### AI agents, working as a team

Expand Down Expand Up @@ -140,7 +144,9 @@ sd ~/code/api # add a folder as a project and open a terminal in it
| `Ctrl+Alt+Arrows` | Focus the pane in that direction |
| `Ctrl+Alt+1…9` | Focus pane 1–9 |
| `Ctrl+Alt+Enter` | Maximize / restore the pane |
| `Ctrl+Alt+Q` | Close the focused terminal |
| `Ctrl+Alt+Q` | Close the focused terminal (offers the background when a command runs) |
| `Ctrl+Alt+P` | Replay the last command |
| `Ctrl+Alt+W` | Send the terminal to the background (keeps running) |
| `Ctrl+Alt+B` | Bookmark picker (Space selects several, Shift+Enter runs) |
| `Ctrl+Alt+E` | Toggle the sidebar |
| `Ctrl+Alt+S` / `Ctrl+Alt+T` | Scheduler / Tasks |
Expand Down Expand Up @@ -222,6 +228,10 @@ sd knowledge verify ID [--status S] mark knowledge VERIFIED, REVIEWED, STALE or
sd switch AGENT [--note TEXT] checkpoint this project's task and continue it with another agent in a new terminal
sd projects / sd project NAME / sd relate PROJECT known projects, one project, link related projects
sd install-skill [AGENT...] [--remove] teach agent CLIs the sd team commands (automatic on server start)
sd notes list|add|append|show|replace|delete Scratchpad notes ("- bullets" are fine; - reads stdin)
sd top [--json] Task manager: machine CPU/RAM/GPU and each terminal's usage
sd ps [TERMINAL] [--json] processes running in a terminal (default: this one)
sd kill TERMINAL PID [--force] end a process inside a terminal, and its children
sd edit FILE / sd view FILE open a file in shelldeck's editor / viewer
sd search QUERY [--root DIR] LLM-free code search with ranked, highlighted snippets
sd render FILE pretty-print code or markdown
Expand Down
169 changes: 169 additions & 0 deletions shelldeck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,155 @@ def handoffs(all_: bool = typer.Option(False, "--all", "-a", help="Every project
typer.echo(f" result: {' '.join(h['result'].split())[:100]}")


notes_app = typer.Typer(help="Scratchpad notes (the Scratchpad page): quick markdown notes for you or other agents.")
app.add_typer(notes_app, name="notes")


def _note_body(text: str) -> str:
"""'-' reads the text from stdin (multi-line notes)."""
return sys.stdin.read() if text == "-" else text


def _note_title(body: str) -> str:
return next((x.lstrip("#>-* ").strip() for x in body.splitlines() if x.lstrip("#>-* ").strip()), "Empty note")


def _note(note_id: str) -> dict:
notes = _api("/api/scratch")["notes"]
found = [n for n in notes if n["id"] == note_id] or [n for n in notes if n["id"].startswith(note_id)]
if len(found) != 1:
typer.echo(f"error: no single note matches {note_id!r} (sd notes list)", err=True)
raise typer.Exit(1)
return found[0]


@notes_app.command("list")
def notes_list(json_: bool = typer.Option(False, "--json", help="Print machine-readable JSON.")):
"""Notes, newest first: id, title, last change."""
notes = _api("/api/scratch")["notes"]
if json_:
typer.echo(json.dumps(notes, indent=2))
return
if not notes:
typer.echo("no notes")
for n in notes:
typer.echo(f"{n['id']} {n['updated_at'][:16].replace('T', ' ')} {_note_title(n['body'])[:70]}")


@notes_app.command("add")
def notes_add(
text: str = typer.Argument(..., help="Markdown; the first line is the title. '-' reads stdin."),
title: str = typer.Option("", "--title", "-t", help="Title line to put first (# Title)."),
):
"""Create a note on the Scratchpad (open pages show it at once)."""
body = _note_body(text).strip()
if title:
body = f"# {title.strip()}\n\n{body}"
if nick := os.environ.get("SHELLDECK_NICK"):
body += f"\n\n_by {nick}_"
n = _api("/api/scratch", "POST", {"body": body + "\n"})
typer.echo(f"note {n['id']}: {_note_title(n['body'])}")


@notes_app.command("append")
def notes_append(note_id: str = typer.Argument(..., help="Note id (or prefix)."), text: str = typer.Argument(..., help="Text to add at the end; '-' reads stdin.")):
"""Add text to the end of a note."""
n = _note(note_id)
body = n["body"].rstrip("\n") + "\n\n" + _note_body(text).strip() + "\n"
_api(f"/api/scratch/{n['id']}", "PUT", {"body": body})
typer.echo(f"appended to {n['id']}")


@notes_app.command("show")
def notes_show(note_id: str = typer.Argument(..., help="Note id (or prefix).")):
"""Print a note's markdown."""
sys.stdout.reconfigure(errors="replace")
typer.echo(_note(note_id)["body"].rstrip("\n"))


@notes_app.command("replace")
def notes_replace(note_id: str = typer.Argument(..., help="Note id (or prefix)."), text: str = typer.Argument(..., help="New markdown; '-' reads stdin.")):
"""Replace a note's whole text."""
n = _note(note_id)
_api(f"/api/scratch/{n['id']}", "PUT", {"body": _note_body(text).rstrip("\n") + "\n"})
typer.echo(f"replaced {n['id']}")


@notes_app.command("delete")
def notes_delete(note_id: str = typer.Argument(..., help="Note id (or prefix).")):
"""Delete a note."""
n = _note(note_id)
_api(f"/api/scratch/{n['id']}", "DELETE")
typer.echo(f"deleted {n['id']}")


def _size(n: float) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return f"{n:.0f} {unit}" if unit in ("B", "KB") else f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} GB"


@app.command()
def top(json_: bool = typer.Option(False, "--json", help="Print machine-readable JSON.")):
"""Task manager: machine CPU/RAM/GPU and each running terminal's usage (shell plus everything it started)."""
st = _api("/api/stats")
if json_:
typer.echo(json.dumps(st, indent=2))
return
sysm = st["system"]
gpu = f" GPU {sysm['gpu']['util']:.0f}%" if sysm.get("gpu") else ""
typer.echo(f"machine: CPU {sysm['cpu']:.0f}% RAM {_size(sysm['mem_used'])} / {_size(sysm['mem_total'])}{gpu}")
names = {x["id"]: x for x in _api("/api/sessions")["sessions"]}
rows = sorted(st["sessions"].items(), key=lambda kv: (-kv[1]["cpu"], -kv[1]["mem"]))
if not rows:
typer.echo("no running terminals")
for sid, u in rows:
s = names.get(sid, {})
busiest = (u.get("agent") or {}).get("label") or (u.get("top") or "-")
ports = f" ports {','.join(map(str, u['ports']))}" if u.get("ports") else ""
typer.echo(f"{(s.get('nick') or sid):<10} CPU {u['cpu']:>5.1f}% RAM {_size(u['mem']):>9} {u['procs']:>3} procs {busiest}{ports}")


@app.command()
def ps(
target: str = typer.Argument("", help="Terminal nick, id or name (default: this terminal)."),
json_: bool = typer.Option(False, "--json", help="Print machine-readable JSON."),
):
"""Processes running in a terminal (below its shell): pid, CPU, memory, command. End one with sd kill."""
sid = _session(target)["id"] if target else _me()
r = _api(f"/api/sessions/{sid}/processes")
if json_:
typer.echo(json.dumps(r, indent=2))
return
if not r["processes"]:
typer.echo("nothing running besides the shell")
for p in sorted(r["processes"], key=lambda p: p["started"]):
typer.echo(f"{p['pid']:>7} CPU {p['cpu']:>5.1f}% {_size(p['mem']):>9} {p['cmd'] or p['name']}"[:200])


@app.command()
def kill(
target: str = typer.Argument(..., help="Terminal nick, id or name."),
pid: int = typer.Argument(..., help="Process id from sd ps."),
force: bool = typer.Option(False, "--force", "-f", help="Kill at once instead of asking it to stop first."),
):
"""End a process running in a terminal, and its children (only processes inside that terminal; never yourself)."""
import psutil # noqa: PLC0415 - only this command needs it

sid = _session(target)["id"]
try:
mine = {p.pid for p in psutil.Process().parents()} | {os.getpid()}
except psutil.Error:
mine = {os.getpid()}
if pid in mine:
typer.echo("error: that process runs you (or your shell); it was not ended", err=True)
raise typer.Exit(1)
r = _api(f"/api/sessions/{sid}/processes/{pid}/end", "POST", {"force": force})
typer.echo(f"{pid} {r['status']}")


@app.command("install-skill")
def install_skill(
agents_: list[str] = typer.Argument(None, metavar="[AGENT]...", help="Agents to set up (default: every agent CLI on PATH)."),
Expand Down Expand Up @@ -1485,8 +1634,28 @@ def _main() -> None:
i += 2 if args[i] in ("--port", "-p") else 1
if i < len(args) and args[i] not in COMMANDS:
sys.argv.insert(i + 1, "open")
elif args[i:i + 2] in (["notes", "add"], ["notes", "append"], ["notes", "replace"]):
sys.argv[i + 3:] = _text_args(args[i + 2:])
app()


def _text_args(rest: list[str]) -> list[str]:
"""Markdown often starts with '-' ("- step one"): keep the known options, pass everything else after `--`
so click doesn't read a bullet as an option."""
opts, words, j = [], [], 0
while j < len(rest):
a = rest[j]
if a in ("--title", "-t") and j + 1 < len(rest):
opts += rest[j:j + 2]
j += 2
continue
if a.startswith("--title=") or a == "--help":
opts.append(a)
elif a != "--":
words.append(a)
j += 1
return [*opts, "--", *words] if words else opts


if __name__ == "__main__":
_main()
Loading