@letta-ai/git-status 0.1.0
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.
- package/MOD.md +57 -0
- package/README.md +72 -0
- package/mods/index.tsx +238 -0
- package/package.json +33 -0
package/MOD.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "@letta-ai/git-status"
|
|
3
|
+
description: "Git branch, dirty/clean, file counts, and ahead/behind for the Letta Code statusline."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Git status statusline mod semantics
|
|
7
|
+
|
|
8
|
+
## When to use
|
|
9
|
+
|
|
10
|
+
Use this mod when the user wants Letta Code's idle statusline to show git state:
|
|
11
|
+
current branch, clean/dirty, changed-file counts, and ahead/behind vs upstream.
|
|
12
|
+
Letta Code's default statusline shows no git information, so this is additive, not
|
|
13
|
+
a replacement for existing git UI.
|
|
14
|
+
|
|
15
|
+
## Behavior
|
|
16
|
+
|
|
17
|
+
- Polls `git` outside the render path on a fixed interval (default 4 seconds).
|
|
18
|
+
- Runs a single read-only command per poll: `git status --porcelain=v2 --branch
|
|
19
|
+
--untracked-files=all`, plus a `rev-parse --is-inside-work-tree` guard.
|
|
20
|
+
- Reads the live working directory from the statusline render context (`ModContext.cwd`),
|
|
21
|
+
captured at render time, so polling follows the session's current directory rather than
|
|
22
|
+
the launch directory. The host `letta` object does not expose a workspace. Falls back to
|
|
23
|
+
`process.cwd()` until the first render.
|
|
24
|
+
- Branch: from `# branch.head`; shows a short SHA (from `# branch.oid`) when detached.
|
|
25
|
+
Long names are truncated to 22 chars with a trailing `…` to avoid overflowing the row.
|
|
26
|
+
- Ahead/behind: from `# branch.ab +A -B`; rendered as `↑A ↓B`, only when an upstream
|
|
27
|
+
exists and there is a non-zero delta.
|
|
28
|
+
- File counts from porcelain v2 entries: `?` untracked → added; staged `A` → added;
|
|
29
|
+
`D` in the XY field → deleted; everything else changed → modified. Rendered as
|
|
30
|
+
`+added ~modified -deleted`.
|
|
31
|
+
- Clean tree renders a check (`✓`).
|
|
32
|
+
- Segment color: green when clean, yellow when dirty.
|
|
33
|
+
- Clears the segment entirely when not inside a git work tree.
|
|
34
|
+
- Renders agent name and model on the right so the row stays useful with no repo.
|
|
35
|
+
|
|
36
|
+
## Platform assumptions
|
|
37
|
+
|
|
38
|
+
Cross-platform. Requires `git` (with porcelain v2 support, git >= 2.11) on `PATH`.
|
|
39
|
+
Uses `windowsHide` so no console window flashes on Windows.
|
|
40
|
+
|
|
41
|
+
## Safety invariants
|
|
42
|
+
|
|
43
|
+
- Renderer stays synchronous; all shelling happens in the poll interval.
|
|
44
|
+
- Only read-only git commands are run; the mod never mutates the repository.
|
|
45
|
+
- Git invocations use a short timeout and swallow errors, degrading to a cleared segment.
|
|
46
|
+
- Timers and status values are cleaned up when the mod is disposed.
|
|
47
|
+
- Optional UI APIs are capability-guarded (`ui.customStatuslineRenderer`, `ui.statusValues`).
|
|
48
|
+
|
|
49
|
+
## Adaptation notes for agents
|
|
50
|
+
|
|
51
|
+
- Keep polling lightweight; raise `REFRESH_MS` if a large repo makes `git status` slow.
|
|
52
|
+
- To add line-level churn, layer in `git diff --numstat` / `--cached --numstat` and a
|
|
53
|
+
sparkline; this mod intentionally stays at the file + branch level.
|
|
54
|
+
- The custom statusline owns the full idle row — if composing with other statusline data,
|
|
55
|
+
merge it into this renderer rather than registering a second renderer.
|
|
56
|
+
- Porcelain v2 is required for the branch/ahead-behind headers; do not downgrade to v1
|
|
57
|
+
without re-deriving branch and upstream separately.
|
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Git Status
|
|
2
|
+
|
|
3
|
+
#git-status
|
|
4
|
+
|
|
5
|
+
A Letta Code statusline mod that shows your current git state in the idle status row: branch, clean/dirty, changed-file counts, and how far you are ahead/behind upstream.
|
|
6
|
+
|
|
7
|
+
Letta Code's default statusline has no git awareness — this fills that gap.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
#install
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
letta install npm:@letta-ai/git-status
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then reload local mods:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
/reload
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What it shows
|
|
24
|
+
|
|
25
|
+
#what-it-shows
|
|
26
|
+
|
|
27
|
+
A git segment on the left of the idle statusline, for example:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
main ↑2 +1 ~3 -1 Letta · claude-sonnet
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- ` main` — current branch (short SHA when in detached HEAD; long names are truncated with `…`)
|
|
34
|
+
- `↑2 ↓1` — commits ahead / behind the upstream branch (only shown when there's an upstream and a delta)
|
|
35
|
+
- `+N` — untracked / newly added files
|
|
36
|
+
- `~N` — modified files (staged or unstaged)
|
|
37
|
+
- `-N` — deleted files
|
|
38
|
+
|
|
39
|
+
When the working tree is clean it shows a check:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
main ✓
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The segment is **green when clean** and **yellow when dirty**, so you can read tree state at a glance. The right side always shows the agent name and model.
|
|
46
|
+
|
|
47
|
+
## Behavior
|
|
48
|
+
|
|
49
|
+
#behavior
|
|
50
|
+
|
|
51
|
+
- Polls `git status --porcelain=v2 --branch` every 4 seconds, outside the render path.
|
|
52
|
+
- Reads from the agent's current workspace directory.
|
|
53
|
+
- Clears the segment when not inside a git work tree.
|
|
54
|
+
- A single git call provides branch, upstream, ahead/behind, and per-file status.
|
|
55
|
+
|
|
56
|
+
## Safety
|
|
57
|
+
|
|
58
|
+
#safety
|
|
59
|
+
|
|
60
|
+
Mods are trusted local code. Review the source before installing third-party mods.
|
|
61
|
+
|
|
62
|
+
This mod only runs read-only `git` commands; it never writes to your repository.
|
|
63
|
+
|
|
64
|
+
If a mod breaks startup or command handling, recover with:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
letta --no-mods
|
|
68
|
+
# or
|
|
69
|
+
LETTA_DISABLE_MODS=1 letta
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
See [MOD.md](./MOD.md) for the agent-facing behavioral contract.
|
package/mods/index.tsx
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
const REFRESH_MS = 4_000;
|
|
7
|
+
const EXEC_TIMEOUT_MS = 1_500;
|
|
8
|
+
// Long branch names can overflow narrow statuslines, so cap the displayed
|
|
9
|
+
// length and add an ellipsis. The full name is never hidden in detached mode
|
|
10
|
+
// (we already show a short SHA there).
|
|
11
|
+
const MAX_BRANCH_CHARS = 22;
|
|
12
|
+
|
|
13
|
+
function truncateBranch(name: string): string {
|
|
14
|
+
if (name.length <= MAX_BRANCH_CHARS) return name;
|
|
15
|
+
return `${name.slice(0, MAX_BRANCH_CHARS - 1)}…`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface GitState {
|
|
19
|
+
/** Current branch name (or short SHA when detached). */
|
|
20
|
+
branch: string;
|
|
21
|
+
/** True when in detached HEAD. */
|
|
22
|
+
detached: boolean;
|
|
23
|
+
/** Number of untracked / newly added files. */
|
|
24
|
+
added: number;
|
|
25
|
+
/** Number of modified (staged or unstaged) files. */
|
|
26
|
+
modified: number;
|
|
27
|
+
/** Number of deleted files. */
|
|
28
|
+
deleted: number;
|
|
29
|
+
/** Commits ahead of upstream. */
|
|
30
|
+
ahead: number;
|
|
31
|
+
/** Commits behind upstream. */
|
|
32
|
+
behind: number;
|
|
33
|
+
/** Whether an upstream is configured. */
|
|
34
|
+
hasUpstream: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const EMPTY_STATE: GitState = {
|
|
38
|
+
branch: "",
|
|
39
|
+
detached: false,
|
|
40
|
+
added: 0,
|
|
41
|
+
modified: 0,
|
|
42
|
+
deleted: 0,
|
|
43
|
+
ahead: 0,
|
|
44
|
+
behind: 0,
|
|
45
|
+
hasUpstream: false,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
async function git(args: string[], cwd: string): Promise<string> {
|
|
49
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
50
|
+
cwd,
|
|
51
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
52
|
+
windowsHide: true,
|
|
53
|
+
});
|
|
54
|
+
return stdout;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readGitState(cwd: string): Promise<GitState | null> {
|
|
58
|
+
// Bail quietly if we are not inside a work tree.
|
|
59
|
+
try {
|
|
60
|
+
const inside = (
|
|
61
|
+
await git(["rev-parse", "--is-inside-work-tree"], cwd)
|
|
62
|
+
).trim();
|
|
63
|
+
if (inside !== "true") return null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const state: GitState = { ...EMPTY_STATE };
|
|
69
|
+
|
|
70
|
+
// Use porcelain v2 with branch headers: a single call gives us branch,
|
|
71
|
+
// upstream, ahead/behind, and per-file status.
|
|
72
|
+
let porcelain = "";
|
|
73
|
+
try {
|
|
74
|
+
porcelain = await git(
|
|
75
|
+
["status", "--porcelain=v2", "--branch", "--untracked-files=all"],
|
|
76
|
+
cwd,
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
return state;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const line of porcelain.split("\n")) {
|
|
83
|
+
if (!line) continue;
|
|
84
|
+
|
|
85
|
+
if (line.startsWith("# branch.head ")) {
|
|
86
|
+
const head = line.slice("# branch.head ".length).trim();
|
|
87
|
+
if (head === "(detached)") {
|
|
88
|
+
state.detached = true;
|
|
89
|
+
} else {
|
|
90
|
+
state.branch = head;
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (line.startsWith("# branch.oid ") && state.detached) {
|
|
96
|
+
// Show a short SHA for detached HEAD.
|
|
97
|
+
const oid = line.slice("# branch.oid ".length).trim();
|
|
98
|
+
if (oid && oid !== "(initial)") state.branch = oid.slice(0, 7);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (line.startsWith("# branch.ab ")) {
|
|
103
|
+
// Format: "# branch.ab +N -M"
|
|
104
|
+
const ab = line.slice("# branch.ab ".length).trim().split(" ");
|
|
105
|
+
const ahead = Number.parseInt(ab[0]?.replace("+", ""), 10);
|
|
106
|
+
const behind = Number.parseInt(ab[1]?.replace("-", ""), 10);
|
|
107
|
+
if (Number.isFinite(ahead)) state.ahead = ahead;
|
|
108
|
+
if (Number.isFinite(behind)) state.behind = behind;
|
|
109
|
+
state.hasUpstream = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Untracked entries start with "? ".
|
|
114
|
+
if (line.startsWith("? ")) {
|
|
115
|
+
state.added += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Changed entries: "1" (ordinary) or "2" (renamed/copied). The XY field
|
|
120
|
+
// is the 2nd token (e.g. ".M", "M.", "MM", "D.", ".D").
|
|
121
|
+
if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
122
|
+
const xy = line.split(" ")[1] ?? "..";
|
|
123
|
+
if (xy.includes("A")) {
|
|
124
|
+
// Staged additions count as "added".
|
|
125
|
+
state.added += 1;
|
|
126
|
+
} else if (xy.includes("D")) {
|
|
127
|
+
state.deleted += 1;
|
|
128
|
+
} else {
|
|
129
|
+
state.modified += 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return state;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function formatSegment(state: GitState): string {
|
|
138
|
+
const parts: string[] = [];
|
|
139
|
+
|
|
140
|
+
// Branch (truncated if long).
|
|
141
|
+
const rawBranch = state.branch || (state.detached ? "detached" : "?");
|
|
142
|
+
parts.push(` ${truncateBranch(rawBranch)}`);
|
|
143
|
+
|
|
144
|
+
// Ahead/behind vs upstream.
|
|
145
|
+
if (state.hasUpstream && (state.ahead || state.behind)) {
|
|
146
|
+
const ab: string[] = [];
|
|
147
|
+
if (state.ahead) ab.push(`↑${state.ahead}`);
|
|
148
|
+
if (state.behind) ab.push(`↓${state.behind}`);
|
|
149
|
+
parts.push(ab.join(""));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// File counts, or a clean marker.
|
|
153
|
+
const fileParts: string[] = [];
|
|
154
|
+
if (state.added) fileParts.push(`+${state.added}`);
|
|
155
|
+
if (state.modified) fileParts.push(`~${state.modified}`);
|
|
156
|
+
if (state.deleted) fileParts.push(`-${state.deleted}`);
|
|
157
|
+
|
|
158
|
+
if (fileParts.length > 0) {
|
|
159
|
+
parts.push(fileParts.join(" "));
|
|
160
|
+
} else {
|
|
161
|
+
parts.push("✓");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return parts.join(" ");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export default function activate(letta: any) {
|
|
168
|
+
if (!letta.capabilities.ui.customStatuslineRenderer) return;
|
|
169
|
+
|
|
170
|
+
let latest: GitState | null = null;
|
|
171
|
+
// The live working directory is provided by the statusline render context
|
|
172
|
+
// (ModContext.cwd), which tracks the session's current directory. The host
|
|
173
|
+
// `letta` object does not expose a workspace, so we capture cwd at render
|
|
174
|
+
// time and let the poll loop read the latest value.
|
|
175
|
+
let currentCwd = process.cwd();
|
|
176
|
+
|
|
177
|
+
const update = async () => {
|
|
178
|
+
if (!letta.capabilities.ui.statusValues) return;
|
|
179
|
+
|
|
180
|
+
let state: GitState | null = null;
|
|
181
|
+
try {
|
|
182
|
+
state = await readGitState(currentCwd);
|
|
183
|
+
} catch {
|
|
184
|
+
state = null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
latest = state;
|
|
188
|
+
if (!state) {
|
|
189
|
+
letta.ui.clearStatus("git");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
letta.ui.setStatus("git", formatSegment(state));
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
letta.ui.setStatuslineRenderer((context: any) => {
|
|
197
|
+
const { Box, Text } = context.components;
|
|
198
|
+
const model = context.model.displayName ?? context.model.id ?? "unknown";
|
|
199
|
+
|
|
200
|
+
// Track the live cwd from the render context so polling follows the
|
|
201
|
+
// session's current directory rather than the launch directory.
|
|
202
|
+
if (typeof context.cwd === "string" && context.cwd) {
|
|
203
|
+
currentCwd = context.cwd;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const dirty =
|
|
207
|
+
!!latest &&
|
|
208
|
+
latest.added + latest.modified + latest.deleted > 0;
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<Box justifyContent="space-between" width="100%">
|
|
212
|
+
<Box>
|
|
213
|
+
{context.statuses.git ? (
|
|
214
|
+
<Text color={dirty ? "yellow" : "green"}>
|
|
215
|
+
{context.statuses.git}
|
|
216
|
+
</Text>
|
|
217
|
+
) : null}
|
|
218
|
+
</Box>
|
|
219
|
+
<Box>
|
|
220
|
+
<Text dimColor>
|
|
221
|
+
{context.agent.name ?? "Letta"}
|
|
222
|
+
{` · ${model}`}
|
|
223
|
+
</Text>
|
|
224
|
+
</Box>
|
|
225
|
+
</Box>
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
void update();
|
|
230
|
+
const timer = setInterval(() => void update(), REFRESH_MS);
|
|
231
|
+
|
|
232
|
+
return () => {
|
|
233
|
+
clearInterval(timer);
|
|
234
|
+
if (letta.capabilities.ui.statusValues) {
|
|
235
|
+
letta.ui.clearStatus("git");
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@letta-ai/git-status",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Letta Code statusline mod that shows the current git branch, dirty/clean state, file counts, and ahead/behind.",
|
|
5
|
+
"author": "christinatong",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"letta-package",
|
|
10
|
+
"letta-mod",
|
|
11
|
+
"letta-code",
|
|
12
|
+
"statusline",
|
|
13
|
+
"git"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/letta-ai/mods.git",
|
|
18
|
+
"directory": "packages/git-status"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"README.md",
|
|
22
|
+
"MOD.md",
|
|
23
|
+
"mods"
|
|
24
|
+
],
|
|
25
|
+
"letta": {
|
|
26
|
+
"manifestVersion": 1,
|
|
27
|
+
"mods": ["./mods/index.tsx"],
|
|
28
|
+
"capabilities": ["ui.statusline", "ui.statusValues"],
|
|
29
|
+
"engines": {
|
|
30
|
+
"lettaCodeCli": ">=0.27.14"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|