@kitfunso/aura 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/LICENSE +21 -0
- package/README.md +179 -0
- package/bin/aura.js +122 -0
- package/package.json +40 -0
- package/src/adapters/frame-win.ps1 +164 -0
- package/src/color.js +48 -0
- package/src/decide.js +113 -0
- package/src/git.js +39 -0
- package/src/hook.js +29 -0
- package/src/install.js +158 -0
- package/src/mark.js +214 -0
- package/src/shell/init.js +16 -0
- package/src/shell/posix.sh +33 -0
- package/src/shell/powershell.ps1 +33 -0
- package/src/state.js +127 -0
- package/src/tag.js +50 -0
- package/src/tty.js +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kitfunso
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# aura
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@kitfunso/aura)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
Color identity for terminal windows. Repo = hue, branch = shade. Any shell
|
|
9
|
+
sitting in a repo gets the color, so Claude Code, Codex, Aider, opencode and a
|
|
10
|
+
plain git prompt all get one from the same place. With 10+ windows open, you
|
|
11
|
+
find the one you want by color, not by reading.
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
npx @kitfunso/aura install --shell powershell
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
One Windows Terminal window, three Claude Code sessions: the active bitfall
|
|
22
|
+
session got a pink tab, a pink-tinted pane, and a pink window border; the aura
|
|
23
|
+
session's tab is blue. Nothing was configured by hand.
|
|
24
|
+
|
|
25
|
+
## What it does
|
|
26
|
+
|
|
27
|
+
When a window enters a repo, aura:
|
|
28
|
+
|
|
29
|
+
1. **Colors the tab** (Windows Terminal, iTerm2) with the repo's color. In
|
|
30
|
+
tabbed windows this is the primary identity surface.
|
|
31
|
+
2. **Tints the pane background** with a dark shade of the same color (OSC 11).
|
|
32
|
+
3. **Paints the real window frame** (border + title bar) via the Windows 11
|
|
33
|
+
DWM API. In stock tabbed Windows Terminal only a 1 px border shows (WT
|
|
34
|
+
draws its own tab strip over the title bar); on floating windows and
|
|
35
|
+
conhost-style terminals the full caption shows.
|
|
36
|
+
4. **Sets the title** to `repo · branch` (best-effort; Claude Code itself
|
|
37
|
+
keeps the latest prompt in the title, which covers "what am I doing here").
|
|
38
|
+
A session colored by its own tab name is the exception: aura writes no title
|
|
39
|
+
there, so the name it read stays put.
|
|
40
|
+
|
|
41
|
+
Three callers, one core. Your shell calls `aura mark` from its prompt when the
|
|
42
|
+
directory changed. Claude Code calls the same core from a hook, which colors a
|
|
43
|
+
session the moment it starts. Any script can call `aura mark --cwd <dir>` when
|
|
44
|
+
it knows a window moved.
|
|
45
|
+
|
|
46
|
+
Colors are deterministic: the same repo maps to the same hue on every machine,
|
|
47
|
+
every restart. Branches get discrete shades of the repo hue; main/master is
|
|
48
|
+
the base shade. With no repo, no tag and no tab name there is no color: the
|
|
49
|
+
window keeps your terminal's own default.
|
|
50
|
+
|
|
51
|
+
So a color means the window is in that repo, or wears that name. It does not
|
|
52
|
+
mean an agent is running in it, and that is the trade for one mechanism every
|
|
53
|
+
tool gets for free.
|
|
54
|
+
|
|
55
|
+
## When the folder is not the project
|
|
56
|
+
|
|
57
|
+
Agents get launched from a home folder as often as from a checkout, and a home
|
|
58
|
+
folder names no project. Six tabs on six projects then share one path that
|
|
59
|
+
names none of them. So identity has two more sources.
|
|
60
|
+
|
|
61
|
+
**Your tab name.** Rename a Windows Terminal tab and aura colors it by that
|
|
62
|
+
name, deterministically, the same way it colors a repo. Nothing to run. Tabs you
|
|
63
|
+
have not renamed are left alone, because aura only takes a title that behaves
|
|
64
|
+
like a name: under 40 characters, no path in it, not a shell's own default, and
|
|
65
|
+
unchanged across two prompts. An agent's title moves with every prompt, so it
|
|
66
|
+
never qualifies. A session colored by its tab name keeps that name, because
|
|
67
|
+
writing a new title would change the color it was just read from.
|
|
68
|
+
|
|
69
|
+
**A tag**, when you want a session to wear a specific repo's color:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
aura tag ~/hippo # this session is hippo, wherever it sits
|
|
73
|
+
aura tag # print the current tag
|
|
74
|
+
aura tag --clear # back to the working directory
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A tag outranks both the working directory and the tab name. Run it from inside
|
|
78
|
+
an agent and it tags that agent's own session. The key comes
|
|
79
|
+
from `CLAUDE_CODE_SESSION_ID`, or from `AURA_SESSION`, which the shell snippet
|
|
80
|
+
exports to every process the window starts. The color lands at once, through
|
|
81
|
+
the same path a hook paints on, so an agent with no prompt hook still gets one.
|
|
82
|
+
A tag lives and dies with its session, so nothing outlives the window that set it.
|
|
83
|
+
|
|
84
|
+
## Install
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
npx @kitfunso/aura install --shell powershell
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
That wraps your PowerShell prompt, so every window in a repo gets the color
|
|
91
|
+
whatever is running inside it. `--shell bash` and `--shell zsh` write the same
|
|
92
|
+
thing into `~/.bashrc` / `~/.zshrc`. The snippet wraps your existing prompt
|
|
93
|
+
instead of replacing it, so posh-git, oh-my-posh and Starship keep working, and
|
|
94
|
+
it lands between two markers so a re-run replaces it instead of stacking.
|
|
95
|
+
|
|
96
|
+
Claude Code also has a native hook, which colors a session the moment it starts
|
|
97
|
+
rather than at its next prompt:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
npx @kitfunso/aura install
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
That merges two hook entries (SessionStart, UserPromptSubmit) into
|
|
104
|
+
`~/.claude/settings.json`. Both installs together are fine: the shell path does
|
|
105
|
+
nothing when the directory has not changed. From a checkout, swap
|
|
106
|
+
`npx @kitfunso/aura` for `node bin/aura.js`.
|
|
107
|
+
|
|
108
|
+
On the first install the target file is copied to `<file>.aura-bak`. That backup
|
|
109
|
+
holds your pre-aura content and later runs never overwrite it. Uninstall takes
|
|
110
|
+
back out exactly what install put in:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
npx @kitfunso/aura uninstall --shell powershell
|
|
114
|
+
npx @kitfunso/aura uninstall
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Colors appear in new shells and new Claude Code sessions. Point either install
|
|
118
|
+
at a different file with `--profile <path>` or `--settings <path>`; the backup
|
|
119
|
+
lands next to that file.
|
|
120
|
+
|
|
121
|
+
Requirements: Windows 11 build 22000+ for the frame color, Windows Terminal
|
|
122
|
+
1.15+ for the tab color, Node.js. Tint + title degrade gracefully elsewhere.
|
|
123
|
+
No runtime dependencies, no network calls, everything local.
|
|
124
|
+
|
|
125
|
+
The frame paint fires in terminals that mark their environment: Windows
|
|
126
|
+
Terminal (verified), wezterm, alacritty, and ghostty (per their docs,
|
|
127
|
+
best-effort). Plain conhost sets no marker and gets no paint.
|
|
128
|
+
|
|
129
|
+
## How it works (and the traps we measured)
|
|
130
|
+
|
|
131
|
+
The design is shaped by four findings, all measured live on 2026-08-30
|
|
132
|
+
(details in `docs/ARCHITECTURE.md` Known Risks):
|
|
133
|
+
|
|
134
|
+
- **Claude Code hooks get their own hidden console on Windows.** A hook's
|
|
135
|
+
`CONOUT$` write succeeds but is invisible. Visible delivery attaches to the
|
|
136
|
+
topmost console-attached ancestor (the tab's real console) from the
|
|
137
|
+
PowerShell adapter, once per session or color change. The per-prompt path
|
|
138
|
+
spawns nothing and stays at ~70 ms.
|
|
139
|
+
- **Windows Terminal runs every window in one process**, so PID matching
|
|
140
|
+
cannot identify a window. The frame paint takes `GetForegroundWindow()` at
|
|
141
|
+
prompt time, allowlisted to terminal processes, and caches the HWND.
|
|
142
|
+
- **Claude Code rewrites the terminal title continuously**, so a
|
|
143
|
+
nonce-title handshake is impossible and aura's title is best-effort.
|
|
144
|
+
- **Tab color needs the DECAC escape** (`OSC 4;264;rgb:RR/GG/BB` +
|
|
145
|
+
`ESC[2;15;264,|`). Slot 264 sits above the 256-color text palette, so your
|
|
146
|
+
text keeps its own colors; aura only moves the background and the frame.
|
|
147
|
+
|
|
148
|
+
The shell path dodges the first of those: a shell prompt owns a visible
|
|
149
|
+
console, so `aura mark --write` puts the escapes there itself and needs no
|
|
150
|
+
PowerShell hop for them. The frame paint still spawns the adapter, once per
|
|
151
|
+
window and color.
|
|
152
|
+
|
|
153
|
+
Repo layout: `src/color.js` (the pure color contract), `src/mark.js` (the core
|
|
154
|
+
every caller goes through), `src/tag.js` (the session tag), `src/hook.js` and
|
|
155
|
+
`bin/aura.js` (the two callers),
|
|
156
|
+
`src/shell/` (the prompt snippets), `src/tty.js` (terminal device),
|
|
157
|
+
`src/adapters/frame-win.ps1` (all Win32 code), `src/install.js` (installer).
|
|
158
|
+
Tests, from the repo root:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
npm test
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Cross-platform
|
|
165
|
+
|
|
166
|
+
The core is OS-neutral; only the tty device path and the frame adapter vary.
|
|
167
|
+
macOS/Linux get tint + title (and iTerm2 tab color) from the same code today;
|
|
168
|
+
frame adapters for them are planned (macOS needs an overlay window, there is
|
|
169
|
+
no API to recolor another app's frame). See the support matrix in
|
|
170
|
+
`docs/ARCHITECTURE.md`.
|
|
171
|
+
|
|
172
|
+
## Lane B (built)
|
|
173
|
+
|
|
174
|
+
The cross-app overlay lives at
|
|
175
|
+
[aura-overlay](https://github.com/kitfunso/aura-overlay): click-through
|
|
176
|
+
colored rings around any window, same repo = hue contract, hotkey tagging for
|
|
177
|
+
non-terminal windows. `src/color.js` is byte-identical in both repos; changes
|
|
178
|
+
flow from here to there, never back. This repo only guarantees the contract
|
|
179
|
+
stays pure. Spike evidence that green-lit it: `docs/LANE-B.md`.
|
package/bin/aura.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// The agent-neutral entry point: whoever knows the working directory changed
|
|
4
|
+
// calls "aura mark". Unlike the hook it may print escapes on stdout, because
|
|
5
|
+
// its caller owns a visible console. Design: docs/ARCHITECTURE.md.
|
|
6
|
+
const { mark } = require("../src/mark.js");
|
|
7
|
+
const { writeToTerminal } = require("../src/tty.js");
|
|
8
|
+
const { shellSnippet, SHELLS } = require("../src/shell/init.js");
|
|
9
|
+
const {
|
|
10
|
+
sessionKey, readTag, writeTag, resolveTarget, inTerminalSession,
|
|
11
|
+
} = require("../src/tag.js");
|
|
12
|
+
|
|
13
|
+
const USAGE = [
|
|
14
|
+
"usage: aura mark [--write] [--cwd <dir>] [--session <id>] [--title <text>]",
|
|
15
|
+
" aura tag [<dir>] [--clear] [--session <id>]",
|
|
16
|
+
" aura install [--shell powershell|bash|zsh] [--settings <path>] [--profile <path>]",
|
|
17
|
+
" aura uninstall [same flags as install]",
|
|
18
|
+
" aura shell-init [--shell powershell|bash|zsh]",
|
|
19
|
+
"",
|
|
20
|
+
" mark print the escape sequences for <dir>, and paint the window frame.",
|
|
21
|
+
" --write sends them to the terminal device instead, falling back",
|
|
22
|
+
" to stdout when that device is not reachable.",
|
|
23
|
+
" tag color this session as <dir> instead of the working directory,",
|
|
24
|
+
" for an agent launched somewhere that names no project. It paints",
|
|
25
|
+
" the window at once, so an agent with no prompt hook still gets a",
|
|
26
|
+
" color. With no argument it prints the tag. --clear goes to cwd.",
|
|
27
|
+
" install with --shell, wire that shell's prompt. Without it, register the",
|
|
28
|
+
" Claude Code hooks in ~/.claude/settings.json.",
|
|
29
|
+
" uninstall take back out what install put in.",
|
|
30
|
+
" shell-init print the prompt snippet to source from a shell profile.",
|
|
31
|
+
].join("\n");
|
|
32
|
+
|
|
33
|
+
function argValue(flag) {
|
|
34
|
+
const index = process.argv.indexOf(flag);
|
|
35
|
+
return index !== -1 && process.argv[index + 1] ? process.argv[index + 1] : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function commandMark() {
|
|
39
|
+
const toTerminal = process.argv.indexOf("--write") !== -1;
|
|
40
|
+
let ttyTarget = null;
|
|
41
|
+
const result = mark({
|
|
42
|
+
cwd: argValue("--cwd") || process.cwd(),
|
|
43
|
+
// The pid alone is not unique for long: the shell snippets add a start second.
|
|
44
|
+
sessionId: argValue("--session") || "shell-" + process.ppid,
|
|
45
|
+
eventName: "prompt",
|
|
46
|
+
promptText: argValue("--title"),
|
|
47
|
+
sink: function (escapes) {
|
|
48
|
+
if (toTerminal) ttyTarget = writeToTerminal(escapes);
|
|
49
|
+
return ttyTarget || "stdout";
|
|
50
|
+
},
|
|
51
|
+
redeliverVt: false,
|
|
52
|
+
});
|
|
53
|
+
if (!ttyTarget) process.stdout.write(result.escapes);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function commandShellInit() {
|
|
57
|
+
const shell = argValue("--shell") || (process.platform === "win32" ? "powershell" : "bash");
|
|
58
|
+
const snippet = shellSnippet(shell, __filename);
|
|
59
|
+
if (!snippet) {
|
|
60
|
+
console.error("aura: unknown shell " + shell + ". Known: " + SHELLS.join(", "));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
process.stdout.write(snippet);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A full-screen agent has no prompt to fire after this, so the tag would sit in
|
|
67
|
+
// state unread. The escapes ride the adapter, the way a hook's do.
|
|
68
|
+
function paintTagged(sessionId) {
|
|
69
|
+
if (!inTerminalSession(process.env)) return;
|
|
70
|
+
try {
|
|
71
|
+
mark({
|
|
72
|
+
cwd: process.cwd(),
|
|
73
|
+
sessionId,
|
|
74
|
+
eventName: "prompt",
|
|
75
|
+
sink: writeToTerminal,
|
|
76
|
+
redeliverVt: true,
|
|
77
|
+
});
|
|
78
|
+
} catch (err) { /* the tag is written; a failed paint must not fail the CLI */ }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function commandTag() {
|
|
82
|
+
const id = sessionKey(process.env, argValue("--session"));
|
|
83
|
+
const clear = process.argv.indexOf("--clear") !== -1;
|
|
84
|
+
const target = process.argv[3] && process.argv[3].charAt(0) !== "-" ? process.argv[3] : null;
|
|
85
|
+
if (!target && !clear) {
|
|
86
|
+
const current = readTag(id);
|
|
87
|
+
console.log(id + ": " + (current || "no tag, using the working directory"));
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
const resolved = clear ? null : resolveTarget(target, process.cwd());
|
|
91
|
+
if (!clear && !resolved) {
|
|
92
|
+
console.error("aura: no such directory: " + target);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
if (!writeTag(id, resolved)) {
|
|
96
|
+
console.error("aura: state file is busy, nothing written");
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
paintTagged(id);
|
|
100
|
+
console.log(clear ? id + ": tag cleared" : id + ": tagged " + resolved);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const command = process.argv[2];
|
|
105
|
+
if (command === "mark") {
|
|
106
|
+
// Rule 6: this runs on a prompt path, so a broken aura prints nothing.
|
|
107
|
+
try { commandMark(); } catch (err) { /* fail silent */ }
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
if (command === "tag") {
|
|
111
|
+
process.exit(commandTag());
|
|
112
|
+
}
|
|
113
|
+
if (command === "shell-init") {
|
|
114
|
+
commandShellInit();
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
if (command === "install" || command === "uninstall") {
|
|
118
|
+
require("../src/install.js").run(command === "uninstall");
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
console.error(USAGE);
|
|
122
|
+
process.exit(command === "--help" || command === "-h" ? 0 : 1);
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kitfunso/aura",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Color identity for Claude Code terminal windows: repo = hue, branch = shade, latest prompt in the title.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "kitfunso",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/kitfunso/aura.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/kitfunso/aura#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/kitfunso/aura/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"aura": "bin/aura.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src/",
|
|
20
|
+
"bin/",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node scripts/check-comments.js && node --test"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"claude-code",
|
|
35
|
+
"terminal",
|
|
36
|
+
"colors",
|
|
37
|
+
"windows-terminal",
|
|
38
|
+
"hooks"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# aura frame adapter, Windows 11 build 22000+. Paints one window frame and
|
|
2
|
+
# delivers the VT payload into the tab's real console. Design: docs/ARCHITECTURE.md.
|
|
3
|
+
# Prints the resolved HWND on stdout first, or 0.
|
|
4
|
+
param(
|
|
5
|
+
[Parameter(Mandatory=$true)][string]$FrameColor, # RRGGBB, no leading #
|
|
6
|
+
[long]$Hwnd = 0,
|
|
7
|
+
[switch]$NoPaint, # resolve and report the HWND, but write no color
|
|
8
|
+
[switch]$QueryTitle, # print the terminal window title and exit; writes nothing
|
|
9
|
+
[switch]$Reset, # write DWMWA_COLOR_DEFAULT: back to the system frame
|
|
10
|
+
[string]$SkipHwnds = "", # -Reset only: comma-joined HWNDs a repo session owns
|
|
11
|
+
[string]$VtB64 = "",
|
|
12
|
+
[int]$VtDelayMs = 0, # >0: hand targets to a delayed hidden writer instead of writing now
|
|
13
|
+
[string]$VtTargets = "", # delayed-writer mode: comma-joined attach-target PIDs, topmost first
|
|
14
|
+
[string]$StateFile = "", # delayed-writer mode: state.json path for the delivery skip check
|
|
15
|
+
[string]$SessionId = "", # delayed-writer mode: which session to check
|
|
16
|
+
[string]$VtSig = "" # delayed-writer mode: signature a prompt would have recorded
|
|
17
|
+
)
|
|
18
|
+
$ErrorActionPreference = "Stop"
|
|
19
|
+
|
|
20
|
+
Add-Type -TypeDefinition @"
|
|
21
|
+
using System;
|
|
22
|
+
using System.Runtime.InteropServices;
|
|
23
|
+
|
|
24
|
+
public static class AuraFrame {
|
|
25
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
26
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
|
27
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, System.Text.StringBuilder buf, int max);
|
|
28
|
+
[DllImport("dwmapi.dll")] public static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int val, int size);
|
|
29
|
+
[DllImport("kernel32.dll")] static extern bool FreeConsole();
|
|
30
|
+
[DllImport("kernel32.dll")] static extern bool AttachConsole(uint pid);
|
|
31
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern IntPtr CreateFileW(string name, uint access, uint share, IntPtr sec, uint disp, uint flags, IntPtr tmpl);
|
|
32
|
+
[DllImport("kernel32.dll")] static extern bool WriteFile(IntPtr h, byte[] buf, uint n, out uint written, IntPtr overlapped);
|
|
33
|
+
[DllImport("kernel32.dll")] static extern bool GetConsoleMode(IntPtr h, out uint mode);
|
|
34
|
+
[DllImport("kernel32.dll")] static extern bool SetConsoleMode(IntPtr h, uint mode);
|
|
35
|
+
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
|
|
36
|
+
|
|
37
|
+
public static bool WriteVt(uint pid, byte[] bytes) {
|
|
38
|
+
FreeConsole();
|
|
39
|
+
if (!AttachConsole(pid)) { return false; }
|
|
40
|
+
IntPtr h = CreateFileW("CONOUT$", 0xC0000000, 3, IntPtr.Zero, 3, 0, IntPtr.Zero);
|
|
41
|
+
if (h == new IntPtr(-1)) { FreeConsole(); return false; }
|
|
42
|
+
uint mode;
|
|
43
|
+
if (GetConsoleMode(h, out mode)) { SetConsoleMode(h, mode | 0x4); }
|
|
44
|
+
uint written;
|
|
45
|
+
bool ok = WriteFile(h, bytes, (uint)bytes.Length, out written, IntPtr.Zero);
|
|
46
|
+
CloseHandle(h);
|
|
47
|
+
FreeConsole();
|
|
48
|
+
return ok;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
"@
|
|
52
|
+
|
|
53
|
+
if ($VtTargets -ne "") {
|
|
54
|
+
# Delayed writer: the parent resolved the targets while the ancestor chain
|
|
55
|
+
# was alive. Skip if a prompt already delivered a newer payload.
|
|
56
|
+
if ($VtDelayMs -gt 0) { Start-Sleep -Milliseconds $VtDelayMs }
|
|
57
|
+
if ($StateFile -ne "" -and $SessionId -ne "" -and (Test-Path -LiteralPath $StateFile)) {
|
|
58
|
+
try {
|
|
59
|
+
$state = Get-Content -Raw -LiteralPath $StateFile | ConvertFrom-Json
|
|
60
|
+
$sess = $state.sessions.$SessionId
|
|
61
|
+
if ($sess -and $VtSig -ne "" -and $sess.vtSent -eq $VtSig) { exit 0 }
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
$bytes = [Convert]::FromBase64String($VtB64)
|
|
66
|
+
foreach ($target in ($VtTargets -split ",")) {
|
|
67
|
+
if ([AuraFrame]::WriteVt([uint32]$target, $bytes)) { break }
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
exit 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
$allowedProcs = @("WindowsTerminal", "OpenConsole", "conhost", "wezterm-gui", "alacritty", "ghostty")
|
|
74
|
+
|
|
75
|
+
function Resolve-TerminalWindow {
|
|
76
|
+
$fg = [AuraFrame]::GetForegroundWindow()
|
|
77
|
+
$procId = [uint32]0
|
|
78
|
+
[void][AuraFrame]::GetWindowThreadProcessId($fg, [ref]$procId)
|
|
79
|
+
$procName = ""
|
|
80
|
+
try { $procName = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
|
|
81
|
+
if ($allowedProcs -contains $procName) { return $fg }
|
|
82
|
+
return [IntPtr]::Zero
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if ($QueryTitle) {
|
|
86
|
+
# In Windows Terminal this is the ACTIVE TAB's name. The cached handle wins,
|
|
87
|
+
# so a foreground window belonging to another app cannot answer for this one.
|
|
88
|
+
$titleWindow = [IntPtr]::Zero
|
|
89
|
+
if ($Hwnd -ne 0) { $titleWindow = [IntPtr]$Hwnd } else { $titleWindow = Resolve-TerminalWindow }
|
|
90
|
+
if ($titleWindow -ne [IntPtr]::Zero) {
|
|
91
|
+
$buffer = New-Object System.Text.StringBuilder -ArgumentList 256
|
|
92
|
+
[void][AuraFrame]::GetWindowText($titleWindow, $buffer, $buffer.Capacity)
|
|
93
|
+
Write-Output $buffer.ToString()
|
|
94
|
+
}
|
|
95
|
+
exit 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
$target = [IntPtr]::Zero
|
|
99
|
+
if ($Hwnd -ne 0) { $target = [IntPtr]$Hwnd } else { $target = Resolve-TerminalWindow }
|
|
100
|
+
|
|
101
|
+
if ($target -ne [IntPtr]::Zero) {
|
|
102
|
+
# The resolved window may differ from the cached one, so the skip list is
|
|
103
|
+
# checked here: a repo session's color outranks a bare shell's reset.
|
|
104
|
+
$skip = $false
|
|
105
|
+
if ($Reset -and $SkipHwnds -ne "") {
|
|
106
|
+
$skip = ($SkipHwnds -split "," | Where-Object { $_ -eq ([int64]$target).ToString() }).Count -gt 0
|
|
107
|
+
}
|
|
108
|
+
if ($Reset -and -not $skip) {
|
|
109
|
+
$default = -1 # DWMWA_COLOR_DEFAULT (0xFFFFFFFF)
|
|
110
|
+
[void][AuraFrame]::DwmSetWindowAttribute($target, 34, [ref]$default, 4)
|
|
111
|
+
[void][AuraFrame]::DwmSetWindowAttribute($target, 35, [ref]$default, 4)
|
|
112
|
+
}
|
|
113
|
+
# -NoPaint callers want the handle only.
|
|
114
|
+
if (-not $NoPaint) {
|
|
115
|
+
# COLORREF is 0x00BBGGRR.
|
|
116
|
+
$r = [Convert]::ToInt32($FrameColor.Substring(0, 2), 16)
|
|
117
|
+
$g = [Convert]::ToInt32($FrameColor.Substring(2, 2), 16)
|
|
118
|
+
$b = [Convert]::ToInt32($FrameColor.Substring(4, 2), 16)
|
|
119
|
+
$colorref = ($b -shl 16) -bor ($g -shl 8) -bor $r
|
|
120
|
+
[void][AuraFrame]::DwmSetWindowAttribute($target, 34, [ref]$colorref, 4) # DWMWA_BORDER_COLOR
|
|
121
|
+
[void][AuraFrame]::DwmSetWindowAttribute($target, 35, [ref]$colorref, 4) # DWMWA_CAPTION_COLOR
|
|
122
|
+
}
|
|
123
|
+
Write-Output ("" + [int64]$target)
|
|
124
|
+
} else {
|
|
125
|
+
Write-Output "0"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if ($VtB64 -ne "") {
|
|
129
|
+
try {
|
|
130
|
+
$bytes = [Convert]::FromBase64String($VtB64)
|
|
131
|
+
# One bulk pid->ppid query, then walk this process's ancestry.
|
|
132
|
+
$parentOf = @{}
|
|
133
|
+
foreach ($p in Get-CimInstance Win32_Process -Property ProcessId, ParentProcessId) {
|
|
134
|
+
$parentOf[[uint32]$p.ProcessId] = [uint32]$p.ParentProcessId
|
|
135
|
+
}
|
|
136
|
+
$chain = @()
|
|
137
|
+
$cur = [uint32]$PID
|
|
138
|
+
for ($i = 0; $i -lt 12; $i++) {
|
|
139
|
+
if (-not $parentOf.ContainsKey($cur)) { break }
|
|
140
|
+
$up = $parentOf[$cur]
|
|
141
|
+
if ($up -eq 0 -or -not $parentOf.ContainsKey($up)) { break }
|
|
142
|
+
$chain += $up
|
|
143
|
+
$cur = $up
|
|
144
|
+
}
|
|
145
|
+
# Topmost first: the highest attachable ancestor owns the tab console;
|
|
146
|
+
# nearer ancestors (the hook's own node/cmd) hold the hidden one.
|
|
147
|
+
[array]::Reverse($chain)
|
|
148
|
+
if ($VtDelayMs -gt 0) {
|
|
149
|
+
# An immediate write races the TUI init, so hand off to a grandchild.
|
|
150
|
+
if ($chain.Length -gt 0) {
|
|
151
|
+
$argList = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath,
|
|
152
|
+
"-FrameColor", $FrameColor, "-VtB64", $VtB64,
|
|
153
|
+
"-VtTargets", ($chain -join ","), "-VtDelayMs", $VtDelayMs)
|
|
154
|
+
if ($StateFile -ne "") { $argList += @("-StateFile", $StateFile) }
|
|
155
|
+
if ($SessionId -ne "") { $argList += @("-SessionId", $SessionId) }
|
|
156
|
+
Start-Process -WindowStyle Hidden -FilePath "powershell.exe" -ArgumentList $argList
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
foreach ($ancestor in $chain) {
|
|
160
|
+
if ([AuraFrame]::WriteVt($ancestor, $bytes)) { break }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
package/src/color.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// THE COLOR CONTRACT. Lane B inherits this file unchanged. Pure: no I/O.
|
|
4
|
+
// Mapping changes are breaking changes (CLAUDE.md rule 7); read
|
|
5
|
+
// docs/ARCHITECTURE.md "The Color Contract" first.
|
|
6
|
+
|
|
7
|
+
const SHADE_COUNT = 4;
|
|
8
|
+
const BASE_BRANCHES = new Set(["main", "master"]);
|
|
9
|
+
|
|
10
|
+
// Frame lightness per shade step. Spread wide so shades stay tellable-apart.
|
|
11
|
+
const FRAME_LIGHTNESS = [50, 63, 39, 72];
|
|
12
|
+
// Tint lightness per shade step. All inside the dark, readable band.
|
|
13
|
+
const TINT_LIGHTNESS = [13, 16, 10, 19];
|
|
14
|
+
|
|
15
|
+
function fnv1a(text) {
|
|
16
|
+
let hash = 0x811c9dc5;
|
|
17
|
+
for (let i = 0; i < text.length; i++) {
|
|
18
|
+
hash ^= text.charCodeAt(i);
|
|
19
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
20
|
+
}
|
|
21
|
+
return hash >>> 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hslToHex(hue, saturationPct, lightnessPct) {
|
|
25
|
+
const s = saturationPct / 100;
|
|
26
|
+
const l = lightnessPct / 100;
|
|
27
|
+
const k = (n) => (n + hue / 30) % 12;
|
|
28
|
+
const a = s * Math.min(l, 1 - l);
|
|
29
|
+
const channel = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
30
|
+
const toHex = (v) => Math.round(v * 255).toString(16).padStart(2, "0");
|
|
31
|
+
return "#" + toHex(channel(0)) + toHex(channel(8)) + toHex(channel(4));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// repoId: origin remote URL, else repo root path, else cwd (non-repo session).
|
|
35
|
+
// branch: git branch name, or null outside a repo.
|
|
36
|
+
function colorsFor({ repoId, branch }) {
|
|
37
|
+
const hue = fnv1a(String(repoId)) % 360;
|
|
38
|
+
const isBase = branch === null || branch === undefined || branch === "" || BASE_BRANCHES.has(branch);
|
|
39
|
+
const shadeIndex = isBase ? 0 : 1 + (fnv1a(String(branch)) % (SHADE_COUNT - 1));
|
|
40
|
+
return {
|
|
41
|
+
hue,
|
|
42
|
+
shadeIndex,
|
|
43
|
+
tintHex: hslToHex(hue, 35, TINT_LIGHTNESS[shadeIndex]),
|
|
44
|
+
frameHex: hslToHex(hue, 70, FRAME_LIGHTNESS[shadeIndex]),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { colorsFor, fnv1a, hslToHex };
|