pi-keyboard 0.1.1 → 0.1.3
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/features/keyboard.feature +10 -2
- package/package.json +2 -2
- package/src/driver.ts +24 -2
- package/src/global-sessions.ts +55 -0
- package/src/state-machine.ts +6 -0
- package/src/types.ts +10 -8
|
@@ -58,8 +58,8 @@ Feature: Pi Keyboard Lighting Indicator
|
|
|
58
58
|
Given a user has submitted a prompt or the agent is executing a turn
|
|
59
59
|
When the agent starts thinking or executing tools
|
|
60
60
|
Then the keyboard state machine updates to "thinking"
|
|
61
|
-
And the
|
|
62
|
-
And the
|
|
61
|
+
And the transition is buffered with a gentle intermediate ramp to avoid abrupt visual changes
|
|
62
|
+
And the keyboard lighting is set to softened oceanic blue breathing
|
|
63
63
|
And the update is applied with --no-save in memory
|
|
64
64
|
|
|
65
65
|
Scenario: Pi transitions to need approval state with yellow blinking light
|
|
@@ -96,6 +96,14 @@ Feature: Pi Keyboard Lighting Indicator
|
|
|
96
96
|
Then the unread status is cleared
|
|
97
97
|
And the keyboard state machine transitions to "thinking"
|
|
98
98
|
|
|
99
|
+
Scenario: Orphaned unread record from an unexpectedly-exited session is cleaned up
|
|
100
|
+
Given a session had an unread chat state and then exited unexpectedly (crash / kill)
|
|
101
|
+
And that session's process is no longer alive, leaving an orphaned unread record
|
|
102
|
+
When a new session starts and sweeps the registry
|
|
103
|
+
Then the orphaned unread record is removed
|
|
104
|
+
And it no longer contributes to the green unread light
|
|
105
|
+
And a live unread session still keeps its green light
|
|
106
|
+
|
|
99
107
|
Scenario: Target lighting zone selection
|
|
100
108
|
Given the user configures the target zone
|
|
101
109
|
When the zone is set to "matrix"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-keyboard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Dynamic VIA/QMK keyboard RGB lighting indicator reflecting Pi states (idle, thinking, unread, need-approval, error)",
|
|
5
5
|
"author": "Frad LEE <fradser@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,4 +36,4 @@
|
|
|
36
36
|
"@earendil-works/pi-coding-agent": "*",
|
|
37
37
|
"@earendil-works/pi-tui": "*"
|
|
38
38
|
}
|
|
39
|
-
}
|
|
39
|
+
}
|
package/src/driver.ts
CHANGED
|
@@ -22,7 +22,7 @@ export function resolveCliPath(customPath?: string): string {
|
|
|
22
22
|
|
|
23
23
|
const standardPaths = [
|
|
24
24
|
join(homedir(), ".local", "bin", "via-rgb"),
|
|
25
|
-
"/opt/homebrew/bin
|
|
25
|
+
"/opt/homebrew/bin", "via-rgb",
|
|
26
26
|
"/usr/local/bin/via-rgb",
|
|
27
27
|
];
|
|
28
28
|
|
|
@@ -42,6 +42,8 @@ export interface HardwareApplyResult {
|
|
|
42
42
|
error?: string;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
46
|
+
|
|
45
47
|
export async function applyKeyboardState(
|
|
46
48
|
state: KeyboardState,
|
|
47
49
|
config: KeyboardConfig,
|
|
@@ -71,7 +73,27 @@ export async function applyKeyboardState(
|
|
|
71
73
|
|
|
72
74
|
const task = async (): Promise<HardwareApplyResult> => {
|
|
73
75
|
try {
|
|
74
|
-
//
|
|
76
|
+
// If state specifies smooth ramp (e.g. thinking blue), apply a gentle buffered transition
|
|
77
|
+
if (def.smoothRamp) {
|
|
78
|
+
const intermediateSat = Math.round(def.sat * 0.6);
|
|
79
|
+
const intermediateBrightness = Math.round(targetBrightness * 0.9);
|
|
80
|
+
const rampArgs = [
|
|
81
|
+
...baseArgs,
|
|
82
|
+
"set",
|
|
83
|
+
"-b",
|
|
84
|
+
String(intermediateBrightness),
|
|
85
|
+
"-e",
|
|
86
|
+
String(def.effect),
|
|
87
|
+
"-s",
|
|
88
|
+
"100",
|
|
89
|
+
"-c",
|
|
90
|
+
`${def.hue} ${intermediateSat}`,
|
|
91
|
+
];
|
|
92
|
+
await execFileAsync(cli, rampArgs, { timeout: 3000 });
|
|
93
|
+
await sleep(150); // 150ms graceful transition buffer
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Set final color, effect, speed, brightness in memory
|
|
75
97
|
const setArgs = [
|
|
76
98
|
...baseArgs,
|
|
77
99
|
"set",
|
package/src/global-sessions.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface SessionGlowRecord {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function getRegistryDir(): string {
|
|
16
|
+
// Test seam: allow pointing at an isolated registry so tests never touch the
|
|
17
|
+
// real ~/.pi/agent/directory-sessions directory.
|
|
18
|
+
const override = process.env.PI_DIRECTORY_SESSIONS_DIR;
|
|
19
|
+
if (override) return override;
|
|
16
20
|
return path.join(os.homedir(), ".pi", "agent", "directory-sessions");
|
|
17
21
|
}
|
|
18
22
|
|
|
@@ -81,6 +85,57 @@ export function removeSessionGlowState(cwd: string, sessionId: string): void {
|
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Sweeps the whole registry and removes glow records whose owning process is no
|
|
90
|
+
* longer alive. Sessions that exit unexpectedly (crash, SIGKILL, terminal closed
|
|
91
|
+
* without a clean session_shutdown) never run removeSessionGlowState, so their
|
|
92
|
+
* leftover unread (settled) records would otherwise pile up and keep the green
|
|
93
|
+
* light on. Called at session start to clear that residue before evaluating.
|
|
94
|
+
*
|
|
95
|
+
* Deliberately cleans only dead-process records: a genuinely live session that
|
|
96
|
+
* is still unread keeps its glow record (and green light). No time-based expiry.
|
|
97
|
+
*/
|
|
98
|
+
export function pruneOrphanedGlowStates(): number {
|
|
99
|
+
const baseDir = getRegistryDir();
|
|
100
|
+
if (!fs.existsSync(baseDir)) return 0;
|
|
101
|
+
|
|
102
|
+
let removed = 0;
|
|
103
|
+
try {
|
|
104
|
+
const dirEntries = fs.readdirSync(baseDir, { withFileTypes: true });
|
|
105
|
+
for (const dirEntry of dirEntries) {
|
|
106
|
+
if (!dirEntry.isDirectory()) continue;
|
|
107
|
+
const subDirPath = path.join(baseDir, dirEntry.name);
|
|
108
|
+
let sessionFiles: string[] = [];
|
|
109
|
+
try {
|
|
110
|
+
sessionFiles = fs.readdirSync(subDirPath);
|
|
111
|
+
} catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const file of sessionFiles) {
|
|
116
|
+
if (!file.endsWith(".json")) continue;
|
|
117
|
+
const filePath = path.join(subDirPath, file);
|
|
118
|
+
try {
|
|
119
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
120
|
+
const record = JSON.parse(content) as SessionGlowRecord;
|
|
121
|
+
if (!record || typeof record !== "object" || typeof record.pid !== "number") continue;
|
|
122
|
+
if (!isProcessAlive(record.pid)) {
|
|
123
|
+
try {
|
|
124
|
+
fs.unlinkSync(filePath);
|
|
125
|
+
removed++;
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// Ignore unreadable / non-glow files (all .json in the shared dir)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// Best-effort sweep
|
|
135
|
+
}
|
|
136
|
+
return removed;
|
|
137
|
+
}
|
|
138
|
+
|
|
84
139
|
export interface GlobalStateSummary {
|
|
85
140
|
effectiveState: KeyboardState;
|
|
86
141
|
hasAnyUnread: boolean;
|
package/src/state-machine.ts
CHANGED
|
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import { applyKeyboardState } from "./driver";
|
|
3
3
|
import {
|
|
4
4
|
evaluateGlobalLightingState,
|
|
5
|
+
pruneOrphanedGlowStates,
|
|
5
6
|
removeSessionGlowState,
|
|
6
7
|
writeSessionGlowState,
|
|
7
8
|
type GlobalStateSummary,
|
|
@@ -114,6 +115,11 @@ export class KeyboardStateMachine {
|
|
|
114
115
|
this.isProcessing = false;
|
|
115
116
|
this.hasUnreadChat = false;
|
|
116
117
|
this.hasFatalError = false;
|
|
118
|
+
// Clean up unread (settled) glow records left behind by sessions that exited
|
|
119
|
+
// unexpectedly (crash / kill / terminal closed). Without this their leftover
|
|
120
|
+
// unread would pile up and keep the green light on. Only dead-process records
|
|
121
|
+
// are removed; a live unread session keeps its glow state.
|
|
122
|
+
pruneOrphanedGlowStates();
|
|
117
123
|
await this.syncAndEvaluate("idle");
|
|
118
124
|
}
|
|
119
125
|
|
package/src/types.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface KeyboardStateDefinition {
|
|
|
20
20
|
brightness: number;
|
|
21
21
|
pattern: KeyboardPattern;
|
|
22
22
|
blinkCount?: number;
|
|
23
|
+
smoothRamp?: boolean;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export interface KeyboardConfig {
|
|
@@ -45,7 +46,7 @@ export const KEYBOARD_STATE_DEFINITIONS: Record<KeyboardState, KeyboardStateDefi
|
|
|
45
46
|
hue: 0,
|
|
46
47
|
sat: 0, // White
|
|
47
48
|
effect: 2, // Breathing
|
|
48
|
-
speed:
|
|
49
|
+
speed: 90,
|
|
49
50
|
brightness: 180,
|
|
50
51
|
pattern: "breathing",
|
|
51
52
|
},
|
|
@@ -54,22 +55,23 @@ export const KEYBOARD_STATE_DEFINITIONS: Record<KeyboardState, KeyboardStateDefi
|
|
|
54
55
|
label: "Green Breathing (Unread Chat)",
|
|
55
56
|
labelZh: "绿色 呼吸灯 (未读消息)",
|
|
56
57
|
hue: 85, // Green
|
|
57
|
-
sat:
|
|
58
|
+
sat: 240,
|
|
58
59
|
effect: 2, // Breathing
|
|
59
|
-
speed:
|
|
60
|
-
brightness:
|
|
60
|
+
speed: 130,
|
|
61
|
+
brightness: 210,
|
|
61
62
|
pattern: "breathing",
|
|
62
63
|
},
|
|
63
64
|
thinking: {
|
|
64
65
|
state: "thinking",
|
|
65
66
|
label: "Blue Breathing (Thinking)",
|
|
66
67
|
labelZh: "蓝色 呼吸灯 (正在思考/执行)",
|
|
67
|
-
hue:
|
|
68
|
-
sat:
|
|
68
|
+
hue: 165, // Calm oceanic blue (softened from harsh 170)
|
|
69
|
+
sat: 210, // Softened saturation (non-glaring)
|
|
69
70
|
effect: 2, // Breathing
|
|
70
|
-
speed: 200
|
|
71
|
-
brightness:
|
|
71
|
+
speed: 120, // Calmed, gentle cadence (softened from 200)
|
|
72
|
+
brightness: 195, // Balanced ambient level (smooth from 180)
|
|
72
73
|
pattern: "breathing",
|
|
74
|
+
smoothRamp: true,
|
|
73
75
|
},
|
|
74
76
|
need_approval: {
|
|
75
77
|
state: "need_approval",
|