pi-keyboard 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/README.md +102 -0
- package/README.zh-CN.md +102 -0
- package/features/keyboard.feature +112 -0
- package/package.json +39 -0
- package/src/config.ts +65 -0
- package/src/driver.ts +166 -0
- package/src/global-sessions.ts +78 -0
- package/src/index.ts +260 -0
- package/src/protocol.ts +62 -0
- package/src/state-machine.ts +176 -0
- package/src/types.ts +98 -0
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# pi-keyboard
|
|
2
|
+
|
|
3
|
+
Dynamic VIA/QMK keyboard RGB lighting indicator reflecting Pi's internal states.
|
|
4
|
+
|
|
5
|
+
Ambient physical awareness of what your AI coding agent is doing right on your mechanical keyboard:
|
|
6
|
+
|
|
7
|
+
| Pi State | Color & Effect | Trigger Condition |
|
|
8
|
+
| :--- | :--- | :--- |
|
|
9
|
+
| **Idle** | **White Breathing** (白色 呼吸灯) | Session started, ready & waiting for user input |
|
|
10
|
+
| **Thinking** | **Blue Breathing** (蓝色 呼吸灯) | Model generating, reasoning, or executing tools |
|
|
11
|
+
| **Unread Chat** | **Green Breathing** (绿色 呼吸灯) | Agent settled with a completed response for user |
|
|
12
|
+
| **Need Approval** | **Yellow Blinking** (黄色 闪烁) | User prompt, question, or interactive permission gate |
|
|
13
|
+
| **Error** | **Red Blinking** (红色 闪烁) | API failure, tool error, or abort |
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Zero Flash Wear (`--no-save`)
|
|
18
|
+
|
|
19
|
+
All hardware updates are executed strictly in memory (`--no-save` mode). Lighting changes modify active RAM state on the keyboard's MCU without writing to EEPROM/Flash, avoiding storage wear across thousands of state transitions.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Multi-Zone Control
|
|
24
|
+
|
|
25
|
+
Supports discrete control for keyboard lighting channels:
|
|
26
|
+
- **All Zones** (`all`): Both per-key backlight and underglow/side strips.
|
|
27
|
+
- **Per-Key Matrix** (`matrix`): Keycap backlight (Channel 3 in VIA/QMK).
|
|
28
|
+
- **Underglow / Side Strips** (`underglow`): Bottom and edge light strips (Channel 2 in VIA/QMK).
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
Install from this monorepo or local path:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pi install ~/Developer/FradSer/pi-packages/packages/keyboard
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
When published to npm:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pi install npm:pi-keyboard
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
### Interactive Menu
|
|
51
|
+
|
|
52
|
+
Type `/keyboard` in any Pi session:
|
|
53
|
+
- **Toggle On / Off**
|
|
54
|
+
- **Test Lighting States** (preview Idle, Thinking, Unread, Approval, Error)
|
|
55
|
+
- **Active Zone Selection** (`all`, `matrix`, `underglow`)
|
|
56
|
+
- **Brightness Scaling** (100%, 75%, 50%, 25%)
|
|
57
|
+
- **Hardware Connection Info**
|
|
58
|
+
|
|
59
|
+
### CLI Commands inside Pi
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
/keyboard on # Enable lighting indicators
|
|
63
|
+
/keyboard off # Disable lighting indicators
|
|
64
|
+
/keyboard status # View active status and hardware info
|
|
65
|
+
/keyboard test thinking # Test blue thinking light
|
|
66
|
+
/keyboard test error # Test red blinking light
|
|
67
|
+
/keyboard test unread # Test green unread chat light
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Standalone CLI: `via-rgb`
|
|
73
|
+
|
|
74
|
+
This package coordinates with the native macOS/Linux `via-rgb` binary (`~/.local/bin/via-rgb`), allowing shell scripts, cron jobs, Raycast, and Hammerspoon to control lighting outside Pi as well:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Query status
|
|
78
|
+
via-rgb status
|
|
79
|
+
|
|
80
|
+
# Turn off / on
|
|
81
|
+
via-rgb off
|
|
82
|
+
via-rgb on -b 200
|
|
83
|
+
|
|
84
|
+
# Set specific color & zone
|
|
85
|
+
via-rgb --zone matrix set -c blue
|
|
86
|
+
via-rgb --zone side set -c warmwhite
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
Settings are saved in `~/.pi/agent/keyboard.json`:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"enabled": true,
|
|
98
|
+
"zone": "all",
|
|
99
|
+
"brightnessScale": 1.0,
|
|
100
|
+
"saveToEeprom": false
|
|
101
|
+
}
|
|
102
|
+
```
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# pi-keyboard
|
|
2
|
+
|
|
3
|
+
基于 VIA/QMK 协议的 Pi 键盘 RGB 状态指示灯插件。
|
|
4
|
+
|
|
5
|
+
通过机械键盘的 RGB 灯光,实时提供 AI Coding Agent 运行状态的环境感知:
|
|
6
|
+
|
|
7
|
+
| Pi 运行状态 | 灯光颜色与效果 | 触发时机 |
|
|
8
|
+
| :--- | :--- | :--- |
|
|
9
|
+
| **Idle (待命)** | **白色 呼吸灯** | 启动完成、等待用户输入新任务 |
|
|
10
|
+
| **Thinking (运行)** | **蓝色 呼吸灯** | 模型生成中、深度思考中或正在执行工具 |
|
|
11
|
+
| **Unread Chat (未读)** | **绿色 呼吸灯** | Agent 回答完成,等待用户查阅 |
|
|
12
|
+
| **Need Approval (确认)** | **黄色 闪烁** | 提问、等待用户输入或交互式权限确认 |
|
|
13
|
+
| **Error (异常)** | **红色 闪烁** | API 报错、工具执行失败或异常中断 |
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 零 Flash 损耗设计(`--no-save`)
|
|
18
|
+
|
|
19
|
+
所有状态变更均以 `--no-save` 纯内存模式向键盘 MCU 发送指令,**不写入 Flash / EEPROM**。即使每天触发数千次思考与交互状态切换,也不会产生硬件擦写寿命损耗。
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 多分区独立控制
|
|
24
|
+
|
|
25
|
+
支持精准控制键盘的不同发光区域:
|
|
26
|
+
- **全部区域** (`all`):按键背光与侧边底灯联动。
|
|
27
|
+
- **按键背光矩阵** (`matrix`):仅控制键帽背光(QMK 通道 3)。
|
|
28
|
+
- **侧边底灯** (`underglow`):仅控制外壳侧边/底部灯带(QMK 通道 2)。
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 安装方式
|
|
33
|
+
|
|
34
|
+
从本地仓库安装:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pi install ~/Developer/FradSer/pi-packages/packages/keyboard
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
当发布到 npm 后也可直接安装:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pi install npm:pi-keyboard
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 使用指南
|
|
49
|
+
|
|
50
|
+
### 交互式菜单
|
|
51
|
+
|
|
52
|
+
在 Pi 对话框中输入 `/keyboard`:
|
|
53
|
+
- **开关切换**:开启或关闭状态指示灯
|
|
54
|
+
- **状态效果测试**:预览测试待命、思考、未读、确认、错误各状态
|
|
55
|
+
- **活动分区配置**:选择全部、仅背光或仅侧灯
|
|
56
|
+
- **亮度比例调节**:100%、75%、50%、25% 亮度随心调节
|
|
57
|
+
- **硬件连接信息**:查看识别到的键盘型号与 VIA 协议版本
|
|
58
|
+
|
|
59
|
+
### 对话内快捷指令
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
/keyboard on # 启用指示灯
|
|
63
|
+
/keyboard off # 关闭指示灯
|
|
64
|
+
/keyboard status # 查看当前配置与键盘硬件状态
|
|
65
|
+
/keyboard test thinking # 预览测试蓝色思考呼吸灯
|
|
66
|
+
/keyboard test error # 预览测试红色报错闪烁灯
|
|
67
|
+
/keyboard test unread # 预览测试绿色未读呼吸灯
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 独立命令行工具:`via-rgb`
|
|
73
|
+
|
|
74
|
+
本插件配套安装了系统级命令 `via-rgb`(位于 `~/.local/bin/via-rgb`),在 Pi 之外也可用于 Shell、Raycast、Hammerspoon 或睡眠唤醒脚本:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# 查询当前状态
|
|
78
|
+
via-rgb status
|
|
79
|
+
|
|
80
|
+
# 开关灯光
|
|
81
|
+
via-rgb off
|
|
82
|
+
via-rgb on -b 200
|
|
83
|
+
|
|
84
|
+
# 自定义颜色与分区
|
|
85
|
+
via-rgb --zone matrix set -c blue
|
|
86
|
+
via-rgb --zone side set -c warmwhite
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 配置文件
|
|
92
|
+
|
|
93
|
+
配置自动保存在 `~/.pi/agent/keyboard.json`:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"enabled": true,
|
|
98
|
+
"zone": "all",
|
|
99
|
+
"brightnessScale": 1.0,
|
|
100
|
+
"saveToEeprom": false
|
|
101
|
+
}
|
|
102
|
+
```
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Feature: Pi Keyboard Lighting Indicator
|
|
2
|
+
As a Pi developer
|
|
3
|
+
I want my VIA/QMK keyboard lighting to dynamically reflect Pi's internal states
|
|
4
|
+
So that I have ambient physical awareness of agent status without looking at the terminal
|
|
5
|
+
|
|
6
|
+
Background:
|
|
7
|
+
Given a VIA-compatible keyboard with Raw-HID support
|
|
8
|
+
And the keyboard has RGB lighting configuration
|
|
9
|
+
|
|
10
|
+
Scenario: Pi transitions to idle state with white breathing light
|
|
11
|
+
Given Pi is in session startup or waiting for new user input
|
|
12
|
+
And no other Pi session is currently running
|
|
13
|
+
When the keyboard state machine updates to "idle"
|
|
14
|
+
Then the keyboard lighting is set to white color
|
|
15
|
+
And the effect mode is set to breathing
|
|
16
|
+
And the update is applied immediately in memory without saving to EEPROM
|
|
17
|
+
|
|
18
|
+
Scenario: Pi transitions to unread chat state with green breathing light
|
|
19
|
+
Given the agent has finished generating a response
|
|
20
|
+
And the user has not yet submitted new input or activated the thread
|
|
21
|
+
When the agent settles with a completed message
|
|
22
|
+
Then the keyboard state machine updates to "unread_chat"
|
|
23
|
+
And the keyboard lighting is set to green color
|
|
24
|
+
And the effect mode is set to breathing
|
|
25
|
+
And the update is applied with --no-save in memory
|
|
26
|
+
|
|
27
|
+
Scenario: User activates thread and marks message as read
|
|
28
|
+
Given the keyboard is currently displaying unread chat state
|
|
29
|
+
When the user activates or focuses the terminal window
|
|
30
|
+
And no other Pi sessions are running
|
|
31
|
+
Then the unread status is cleared
|
|
32
|
+
And the keyboard lighting transitions to white idle breathing
|
|
33
|
+
|
|
34
|
+
Scenario: User activates thread while another session is running
|
|
35
|
+
Given the keyboard is currently displaying unread chat state
|
|
36
|
+
When the user activates or focuses the terminal window
|
|
37
|
+
And another background Pi session is currently running
|
|
38
|
+
Then the unread status is cleared
|
|
39
|
+
And the keyboard lighting transitions to blue thinking breathing
|
|
40
|
+
|
|
41
|
+
Scenario: Pi transitions to thinking state with blue breathing light
|
|
42
|
+
Given a user has submitted a prompt or the agent is executing a turn
|
|
43
|
+
When the agent starts thinking or executing tools
|
|
44
|
+
Then the keyboard state machine updates to "thinking"
|
|
45
|
+
And the keyboard lighting is set to blue color
|
|
46
|
+
And the effect mode is set to breathing
|
|
47
|
+
And the update is applied with --no-save in memory
|
|
48
|
+
|
|
49
|
+
Scenario: Pi transitions to need approval state with yellow blinking light
|
|
50
|
+
Given a tool call requires user confirmation or approval
|
|
51
|
+
When an interactive gate or question is prompted
|
|
52
|
+
Then the keyboard state machine updates to "need_approval"
|
|
53
|
+
And the keyboard lighting is set to yellow color
|
|
54
|
+
And the effect mode is set to blinking alert
|
|
55
|
+
And the update is applied with --no-save in memory
|
|
56
|
+
|
|
57
|
+
Scenario: Pi transitions to error state with red blinking light
|
|
58
|
+
Given an agent execution error, API failure, or turn error occurs
|
|
59
|
+
When a fatal error event is captured
|
|
60
|
+
Then the keyboard state machine updates to "error"
|
|
61
|
+
And the keyboard lighting is set to red color
|
|
62
|
+
And the effect mode is set to blinking alert
|
|
63
|
+
And the update is applied with --no-save in memory
|
|
64
|
+
|
|
65
|
+
Scenario: Non-fatal tool errors do not trigger red blinking light
|
|
66
|
+
Given the agent executes a bash command or test that returns a non-zero exit code
|
|
67
|
+
When the tool result arrives during ongoing turn reasoning
|
|
68
|
+
Then the keyboard lighting remains in "thinking" blue breathing state
|
|
69
|
+
And it does not trigger the red error alert
|
|
70
|
+
|
|
71
|
+
Scenario: Upstream provider rate limit (429) triggers red blinking error light
|
|
72
|
+
Given the model provider returns an HTTP 429 quota error or 500 failure
|
|
73
|
+
When the provider error response is received
|
|
74
|
+
Then the keyboard state machine transitions to "error" red blinking
|
|
75
|
+
And the subsequent agent settle event does not overwrite it with green
|
|
76
|
+
|
|
77
|
+
Scenario: User submits input and clears unread chat status
|
|
78
|
+
Given the keyboard is currently displaying unread chat state
|
|
79
|
+
When the user submits a new prompt in the conversation
|
|
80
|
+
Then the unread status is cleared
|
|
81
|
+
And the keyboard state machine transitions to "thinking"
|
|
82
|
+
|
|
83
|
+
Scenario: Target lighting zone selection
|
|
84
|
+
Given the user configures the target zone
|
|
85
|
+
When the zone is set to "matrix"
|
|
86
|
+
Then only channel 3 (key backlight) is updated
|
|
87
|
+
When the zone is set to "underglow"
|
|
88
|
+
Then only channel 2 (side strip) is updated
|
|
89
|
+
When the zone is set to "all"
|
|
90
|
+
Then both channel 2 and channel 3 are updated
|
|
91
|
+
|
|
92
|
+
Scenario: In-memory updates without EEPROM wear
|
|
93
|
+
Given any state transition is dispatched to hardware
|
|
94
|
+
When the HID command is executed
|
|
95
|
+
Then it operates strictly in RAM using no-save mode without sending save command 0x09
|
|
96
|
+
|
|
97
|
+
Scenario: State change deduplication prevents redundant HID writes
|
|
98
|
+
Given the keyboard is already displaying "thinking" state
|
|
99
|
+
When another turn or tool call event occurs while still "thinking"
|
|
100
|
+
Then redundant hardware HID commands are skipped
|
|
101
|
+
|
|
102
|
+
Scenario: Keyboard disconnection handling
|
|
103
|
+
Given the keyboard is unplugged or unavailable
|
|
104
|
+
When a state transition occurs
|
|
105
|
+
Then the driver fails gracefully without throwing unhandled exceptions or crashing Pi
|
|
106
|
+
|
|
107
|
+
Scenario: /keyboard command allows manual state testing and toggle
|
|
108
|
+
Given the /keyboard command is executed
|
|
109
|
+
When the user toggles lighting to "off"
|
|
110
|
+
Then all keyboard lighting updates are disabled
|
|
111
|
+
When the user runs "/keyboard test thinking"
|
|
112
|
+
Then the keyboard lighting displays the blue thinking state
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-keyboard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dynamic VIA/QMK keyboard RGB lighting indicator reflecting Pi states (idle, thinking, unread, need-approval, error)",
|
|
5
|
+
"author": "Frad LEE <fradser@gmail.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"keyboard",
|
|
11
|
+
"lighting",
|
|
12
|
+
"via",
|
|
13
|
+
"qmk",
|
|
14
|
+
"rgb-status",
|
|
15
|
+
"ambient-indicator"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/FradSer/pi-packages.git"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"features",
|
|
27
|
+
"README.md",
|
|
28
|
+
"README.zh-CN.md"
|
|
29
|
+
],
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./src/index.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
37
|
+
"@earendil-works/pi-tui": "*"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { KeyboardConfig, KeyboardZone } from "./types";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_KEYBOARD_CONFIG: KeyboardConfig = {
|
|
7
|
+
enabled: true,
|
|
8
|
+
zone: "all",
|
|
9
|
+
brightnessScale: 1.0,
|
|
10
|
+
saveToEeprom: false, // strictly in-memory (--no-save) by default
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function keyboardConfigPath(): string {
|
|
14
|
+
return join(homedir(), ".pi", "agent", "keyboard.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readKeyboardConfig(): KeyboardConfig {
|
|
18
|
+
const filePath = keyboardConfigPath();
|
|
19
|
+
if (!existsSync(filePath)) {
|
|
20
|
+
return { ...DEFAULT_KEYBOARD_CONFIG };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (!parsed || typeof parsed !== "object") {
|
|
27
|
+
return { ...DEFAULT_KEYBOARD_CONFIG };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const zone: KeyboardZone =
|
|
31
|
+
parsed.zone === "underglow" || parsed.zone === "matrix" || parsed.zone === "all"
|
|
32
|
+
? parsed.zone
|
|
33
|
+
: DEFAULT_KEYBOARD_CONFIG.zone;
|
|
34
|
+
|
|
35
|
+
const brightnessScale =
|
|
36
|
+
typeof parsed.brightnessScale === "number" &&
|
|
37
|
+
parsed.brightnessScale >= 0.1 &&
|
|
38
|
+
parsed.brightnessScale <= 1.0
|
|
39
|
+
? parsed.brightnessScale
|
|
40
|
+
: DEFAULT_KEYBOARD_CONFIG.brightnessScale;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_KEYBOARD_CONFIG.enabled,
|
|
44
|
+
zone,
|
|
45
|
+
brightnessScale,
|
|
46
|
+
saveToEeprom:
|
|
47
|
+
typeof parsed.saveToEeprom === "boolean"
|
|
48
|
+
? parsed.saveToEeprom
|
|
49
|
+
: DEFAULT_KEYBOARD_CONFIG.saveToEeprom,
|
|
50
|
+
cliPath: typeof parsed.cliPath === "string" ? parsed.cliPath : undefined,
|
|
51
|
+
};
|
|
52
|
+
} catch {
|
|
53
|
+
return { ...DEFAULT_KEYBOARD_CONFIG };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function writeKeyboardConfig(config: KeyboardConfig): void {
|
|
58
|
+
const filePath = keyboardConfigPath();
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
61
|
+
writeFileSync(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
62
|
+
} catch {
|
|
63
|
+
// Non-fatal config write failure
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/driver.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import {
|
|
7
|
+
KEYBOARD_STATE_DEFINITIONS,
|
|
8
|
+
type KeyboardConfig,
|
|
9
|
+
type KeyboardDeviceInfo,
|
|
10
|
+
type KeyboardState,
|
|
11
|
+
} from "./types";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
let activeBlinkTimer: NodeJS.Timeout | null = null;
|
|
16
|
+
let commandQueue: Promise<void> = Promise.resolve();
|
|
17
|
+
|
|
18
|
+
export function resolveCliPath(customPath?: string): string {
|
|
19
|
+
if (customPath && existsSync(customPath)) {
|
|
20
|
+
return customPath;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const standardPaths = [
|
|
24
|
+
join(homedir(), ".local", "bin", "via-rgb"),
|
|
25
|
+
"/opt/homebrew/bin/via-rgb",
|
|
26
|
+
"/usr/local/bin/via-rgb",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const p of standardPaths) {
|
|
30
|
+
if (existsSync(p)) {
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return "via-rgb"; // fallback to PATH lookup
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HardwareApplyResult {
|
|
39
|
+
success: boolean;
|
|
40
|
+
state: KeyboardState;
|
|
41
|
+
zone: string;
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function applyKeyboardState(
|
|
46
|
+
state: KeyboardState,
|
|
47
|
+
config: KeyboardConfig,
|
|
48
|
+
): Promise<HardwareApplyResult> {
|
|
49
|
+
if (!config.enabled) {
|
|
50
|
+
return { success: true, state, zone: config.zone };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const def = KEYBOARD_STATE_DEFINITIONS[state];
|
|
54
|
+
if (!def) {
|
|
55
|
+
return { success: false, state, zone: config.zone, error: `Unknown state: ${state}` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Clear any ongoing software blink
|
|
59
|
+
if (activeBlinkTimer) {
|
|
60
|
+
clearInterval(activeBlinkTimer);
|
|
61
|
+
activeBlinkTimer = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const targetBrightness = Math.round(def.brightness * config.brightnessScale);
|
|
65
|
+
const cli = resolveCliPath(config.cliPath);
|
|
66
|
+
|
|
67
|
+
const baseArgs = ["--zone", config.zone];
|
|
68
|
+
if (!config.saveToEeprom) {
|
|
69
|
+
baseArgs.push("--no-save");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const task = async (): Promise<HardwareApplyResult> => {
|
|
73
|
+
try {
|
|
74
|
+
// Set color, effect, speed, brightness in memory
|
|
75
|
+
const setArgs = [
|
|
76
|
+
...baseArgs,
|
|
77
|
+
"set",
|
|
78
|
+
"-b",
|
|
79
|
+
String(targetBrightness),
|
|
80
|
+
"-e",
|
|
81
|
+
String(def.effect),
|
|
82
|
+
"-s",
|
|
83
|
+
String(def.speed),
|
|
84
|
+
"-c",
|
|
85
|
+
`${def.hue} ${def.sat}`,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
await execFileAsync(cli, setArgs, { timeout: 3000 });
|
|
89
|
+
|
|
90
|
+
// If state specifies a blinking alert (yellow / red), run a short asynchronous strobe
|
|
91
|
+
if (def.pattern === "blinking" && (def.blinkCount ?? 0) > 0) {
|
|
92
|
+
startSoftwareStrobe(cli, baseArgs, targetBrightness, def.blinkCount ?? 4);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { success: true, state, zone: config.zone };
|
|
96
|
+
} catch (err: unknown) {
|
|
97
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
98
|
+
return { success: false, state, zone: config.zone, error: msg };
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Queue to ensure strictly ordered HID writes
|
|
103
|
+
commandQueue = commandQueue.then(
|
|
104
|
+
async () => {
|
|
105
|
+
await task();
|
|
106
|
+
},
|
|
107
|
+
async () => {
|
|
108
|
+
await task();
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
await commandQueue;
|
|
112
|
+
return { success: true, state, zone: config.zone };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function startSoftwareStrobe(
|
|
116
|
+
cli: string,
|
|
117
|
+
baseArgs: string[],
|
|
118
|
+
maxBrightness: number,
|
|
119
|
+
cycles: number,
|
|
120
|
+
): void {
|
|
121
|
+
let step = 0;
|
|
122
|
+
const totalSteps = cycles * 2;
|
|
123
|
+
|
|
124
|
+
activeBlinkTimer = setInterval(() => {
|
|
125
|
+
step++;
|
|
126
|
+
const isBright = step % 2 === 0;
|
|
127
|
+
const brightness = isBright ? maxBrightness : 0;
|
|
128
|
+
|
|
129
|
+
execFile(cli, [...baseArgs, "set", "-b", String(brightness)], { timeout: 1500 }, () => {});
|
|
130
|
+
|
|
131
|
+
if (step >= totalSteps) {
|
|
132
|
+
if (activeBlinkTimer) {
|
|
133
|
+
clearInterval(activeBlinkTimer);
|
|
134
|
+
activeBlinkTimer = null;
|
|
135
|
+
}
|
|
136
|
+
// Restore target brightness at the end
|
|
137
|
+
execFile(cli, [...baseArgs, "set", "-b", String(maxBrightness)], { timeout: 1500 }, () => {});
|
|
138
|
+
}
|
|
139
|
+
}, 180);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function fetchKeyboardStatus(
|
|
143
|
+
config: KeyboardConfig,
|
|
144
|
+
): Promise<{
|
|
145
|
+
connected: boolean;
|
|
146
|
+
device?: KeyboardDeviceInfo;
|
|
147
|
+
protocolVersion?: string;
|
|
148
|
+
error?: string;
|
|
149
|
+
}> {
|
|
150
|
+
const cli = resolveCliPath(config.cliPath);
|
|
151
|
+
try {
|
|
152
|
+
const { stdout } = await execFileAsync(cli, ["--json", "status"], { timeout: 3000 });
|
|
153
|
+
const parsed = JSON.parse(stdout);
|
|
154
|
+
return {
|
|
155
|
+
connected: true,
|
|
156
|
+
device: parsed.device,
|
|
157
|
+
protocolVersion: parsed.protocol_version,
|
|
158
|
+
};
|
|
159
|
+
} catch (err: unknown) {
|
|
160
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
161
|
+
return {
|
|
162
|
+
connected: false,
|
|
163
|
+
error: msg,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface SessionRecord {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
pid: number;
|
|
8
|
+
cwd: string;
|
|
9
|
+
status: "running" | "idle" | "settled" | "exited";
|
|
10
|
+
updatedAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getRegistryDir(): string {
|
|
14
|
+
return path.join(os.homedir(), ".pi", "agent", "directory-sessions");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isProcessAlive(pid: number): boolean {
|
|
18
|
+
if (!pid || pid <= 0) return false;
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, 0);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Scans all directory session registries to check if any other session is currently running.
|
|
29
|
+
* Automatically checks PID vitality and excludes dead or stale processes.
|
|
30
|
+
*/
|
|
31
|
+
export function hasOtherRunningSessions(excludePid: number = process.pid): boolean {
|
|
32
|
+
const baseDir = getRegistryDir();
|
|
33
|
+
if (!fs.existsSync(baseDir)) return false;
|
|
34
|
+
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const maxStaleAgeMs = 5 * 60 * 1000; // 5 minutes max stale threshold
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const dirEntries = fs.readdirSync(baseDir, { withFileTypes: true });
|
|
40
|
+
for (const dirEntry of dirEntries) {
|
|
41
|
+
if (!dirEntry.isDirectory()) continue;
|
|
42
|
+
|
|
43
|
+
const subDirPath = path.join(baseDir, dirEntry.name);
|
|
44
|
+
let sessionFiles: string[] = [];
|
|
45
|
+
try {
|
|
46
|
+
sessionFiles = fs.readdirSync(subDirPath);
|
|
47
|
+
} catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const file of sessionFiles) {
|
|
52
|
+
if (!file.endsWith(".json")) continue;
|
|
53
|
+
const filePath = path.join(subDirPath, file);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
57
|
+
const record = JSON.parse(content) as SessionRecord;
|
|
58
|
+
|
|
59
|
+
if (!record || typeof record !== "object") continue;
|
|
60
|
+
if (record.pid === excludePid) continue;
|
|
61
|
+
|
|
62
|
+
// Check if process is alive and actively running
|
|
63
|
+
if (record.status === "running" && isProcessAlive(record.pid)) {
|
|
64
|
+
if (now - (record.updatedAt || 0) < maxStaleAgeMs) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Ignore unreadable or corrupt temp files
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return false;
|
|
78
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { readKeyboardConfig, writeKeyboardConfig } from "./config";
|
|
6
|
+
import { fetchKeyboardStatus } from "./driver";
|
|
7
|
+
import { KeyboardStateMachine } from "./state-machine";
|
|
8
|
+
import {
|
|
9
|
+
KEYBOARD_STATE_DEFINITIONS,
|
|
10
|
+
type KeyboardConfig,
|
|
11
|
+
type KeyboardState,
|
|
12
|
+
type KeyboardZone,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
let stateMachine: KeyboardStateMachine | null = null;
|
|
16
|
+
|
|
17
|
+
function getStateMachine(): KeyboardStateMachine {
|
|
18
|
+
if (!stateMachine) {
|
|
19
|
+
const config = readKeyboardConfig();
|
|
20
|
+
stateMachine = new KeyboardStateMachine(config);
|
|
21
|
+
}
|
|
22
|
+
return stateMachine;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function (pi: ExtensionAPI): void {
|
|
26
|
+
const sm = getStateMachine();
|
|
27
|
+
let terminalInputDetached: (() => void) | undefined;
|
|
28
|
+
|
|
29
|
+
// Pi Lifecycle Hooks
|
|
30
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
31
|
+
// Attach passive terminal input / focus observer to detect thread activation
|
|
32
|
+
if (ctx.hasUI && typeof ctx.ui.onTerminalInput === "function" && !terminalInputDetached) {
|
|
33
|
+
terminalInputDetached = ctx.ui.onTerminalInput((_data: string) => {
|
|
34
|
+
if (sm.isUnread()) {
|
|
35
|
+
void sm.onUserActivated();
|
|
36
|
+
}
|
|
37
|
+
return undefined; // Passive observer: never consume or block input
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await sm.onSessionStart();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
pi.on("agent_start", async (_event, _ctx) => {
|
|
45
|
+
await sm.onAgentStart();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
pi.on("turn_start", async (_event, _ctx) => {
|
|
49
|
+
await sm.onTurnStart();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
pi.on("after_provider_response", async (event, _ctx) => {
|
|
53
|
+
// Detect upstream provider errors (429 rate limit, 401 auth, 500 error, etc.)
|
|
54
|
+
if (event.status && event.status >= 400) {
|
|
55
|
+
await sm.onProviderResponse(event.status);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
pi.on("tool_call", async (event, _ctx) => {
|
|
60
|
+
const input = event.input && typeof event.input === "object" ? (event.input as Record<string, unknown>) : undefined;
|
|
61
|
+
await sm.onToolCall(event.toolName, input);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
pi.on("tool_result", async (_event, _ctx) => {
|
|
65
|
+
await sm.onToolResult();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
pi.on("turn_end", async (event, _ctx) => {
|
|
69
|
+
const msg = event.message as { stopReason?: string; errorMessage?: string } | undefined;
|
|
70
|
+
if (msg?.stopReason === "error" || msg?.stopReason === "aborted" || Boolean(msg?.errorMessage)) {
|
|
71
|
+
await sm.onMessageEnd(msg?.stopReason, msg?.errorMessage);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
pi.on("message_end", async (event, _ctx) => {
|
|
76
|
+
const msg = event.message as { stopReason?: string; errorMessage?: string } | undefined;
|
|
77
|
+
if (msg?.stopReason === "error" || msg?.stopReason === "aborted" || Boolean(msg?.errorMessage)) {
|
|
78
|
+
await sm.onMessageEnd(msg?.stopReason, msg?.errorMessage);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
83
|
+
let hasError = sm.hasError();
|
|
84
|
+
|
|
85
|
+
// Inspect session branch entries to detect trailing assistant errors
|
|
86
|
+
const branch = ctx.sessionManager?.getBranch?.();
|
|
87
|
+
if (branch && branch.length > 0) {
|
|
88
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
89
|
+
const entry = branch[i];
|
|
90
|
+
if (entry && entry.type === "message" && entry.message && entry.message.role === "assistant") {
|
|
91
|
+
const msg = entry.message as { stopReason?: string; errorMessage?: string };
|
|
92
|
+
if (msg.stopReason === "error" || msg.stopReason === "aborted" || Boolean(msg.errorMessage)) {
|
|
93
|
+
hasError = true;
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await sm.onAgentSettled(hasError);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
pi.on("input", async (_event, _ctx) => {
|
|
104
|
+
await sm.onUserInput();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
108
|
+
terminalInputDetached?.();
|
|
109
|
+
terminalInputDetached = undefined;
|
|
110
|
+
await sm.onShutdown();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Register /keyboard Command & Menu
|
|
114
|
+
pi.registerCommand("keyboard", {
|
|
115
|
+
description: "Manage VIA/QMK keyboard RGB lighting status indicators",
|
|
116
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
117
|
+
const trimmed = args.trim().toLowerCase();
|
|
118
|
+
|
|
119
|
+
if (trimmed === "on") {
|
|
120
|
+
sm.updateConfig({ enabled: true });
|
|
121
|
+
writeKeyboardConfig(sm.getConfig());
|
|
122
|
+
ctx.ui.notify("Keyboard lighting indicator enabled", "info");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (trimmed === "off") {
|
|
127
|
+
sm.updateConfig({ enabled: false });
|
|
128
|
+
writeKeyboardConfig(sm.getConfig());
|
|
129
|
+
ctx.ui.notify("Keyboard lighting indicator disabled", "info");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (trimmed === "status") {
|
|
134
|
+
await showStatus(ctx, sm);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (trimmed.startsWith("test")) {
|
|
139
|
+
const parts = trimmed.split(/\s+/);
|
|
140
|
+
const targetState = (parts[1] || "thinking") as KeyboardState;
|
|
141
|
+
if (targetState in KEYBOARD_STATE_DEFINITIONS) {
|
|
142
|
+
await sm.transitionTo(targetState, true);
|
|
143
|
+
ctx.ui.notify(`Testing state: ${KEYBOARD_STATE_DEFINITIONS[targetState].labelZh}`, "info");
|
|
144
|
+
} else {
|
|
145
|
+
ctx.ui.notify(`Unknown test state. Options: ${Object.keys(KEYBOARD_STATE_DEFINITIONS).join(", ")}`, "error");
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Interactive Menu
|
|
151
|
+
await openKeyboardMenu(ctx, sm);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function showStatus(ctx: ExtensionCommandContext, sm: KeyboardStateMachine): Promise<void> {
|
|
157
|
+
const config = sm.getConfig();
|
|
158
|
+
const hw = await fetchKeyboardStatus(config);
|
|
159
|
+
const current = sm.getCurrentState();
|
|
160
|
+
|
|
161
|
+
const lines = [
|
|
162
|
+
`Indicator Status : ${config.enabled ? "Enabled (On)" : "Disabled (Off)"}`,
|
|
163
|
+
`Current State : ${sm.getStateLabel(current)}`,
|
|
164
|
+
`Active Zone : ${config.zone.toUpperCase()}`,
|
|
165
|
+
`Brightness Scale : ${Math.round(config.brightnessScale * 100)}%`,
|
|
166
|
+
`Hardware Status : ${hw.connected ? `Connected (${hw.device?.product || "VIA Keyboard"})` : "Not connected / Not detected"}`,
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function openKeyboardMenu(
|
|
173
|
+
ctx: ExtensionCommandContext,
|
|
174
|
+
sm: KeyboardStateMachine,
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
const config = sm.getConfig();
|
|
177
|
+
const current = sm.getCurrentState();
|
|
178
|
+
|
|
179
|
+
const toggleOption = config.enabled ? "Disable lighting indicator" : "Enable lighting indicator";
|
|
180
|
+
const mainOptions = [
|
|
181
|
+
toggleOption,
|
|
182
|
+
"Test Lighting States (Idle, Thinking, Unread, Approval, Error)",
|
|
183
|
+
`Set Active Zone (Current: ${config.zone.toUpperCase()})`,
|
|
184
|
+
`Set Brightness Scale (Current: ${Math.round(config.brightnessScale * 100)}%)`,
|
|
185
|
+
"View Hardware Status",
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
const choice = await ctx.ui.select(
|
|
189
|
+
`Keyboard Lighting [${config.enabled ? "ON" : "OFF"} · State: ${sm.getStateLabel(current)}]`,
|
|
190
|
+
mainOptions,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
if (!choice) return;
|
|
194
|
+
|
|
195
|
+
if (choice === toggleOption) {
|
|
196
|
+
const next = !config.enabled;
|
|
197
|
+
sm.updateConfig({ enabled: next });
|
|
198
|
+
writeKeyboardConfig(sm.getConfig());
|
|
199
|
+
ctx.ui.notify(`Keyboard lighting indicator is now ${next ? "ON" : "OFF"}`, "info");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (choice.startsWith("Test Lighting States")) {
|
|
204
|
+
const stateKeys = Object.keys(KEYBOARD_STATE_DEFINITIONS) as KeyboardState[];
|
|
205
|
+
const stateOptions = stateKeys.map((st) => KEYBOARD_STATE_DEFINITIONS[st].labelZh);
|
|
206
|
+
const stateChoice = await ctx.ui.select("Select a state to test on keyboard:", stateOptions);
|
|
207
|
+
if (stateChoice) {
|
|
208
|
+
const idx = stateOptions.indexOf(stateChoice);
|
|
209
|
+
const chosenState = stateKeys[idx];
|
|
210
|
+
if (chosenState) {
|
|
211
|
+
await sm.transitionTo(chosenState, true);
|
|
212
|
+
ctx.ui.notify(`Applied state: ${KEYBOARD_STATE_DEFINITIONS[chosenState].labelZh}`, "info");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (choice.startsWith("Set Active Zone")) {
|
|
219
|
+
const zoneOptions = [
|
|
220
|
+
"All Zones (Both Backlight & Underglow)",
|
|
221
|
+
"Per-Key Backlight Matrix (Channel 3)",
|
|
222
|
+
"Underglow / Side Strips (Channel 2)",
|
|
223
|
+
];
|
|
224
|
+
const zoneChoice = await ctx.ui.select("Select target lighting zone:", zoneOptions);
|
|
225
|
+
if (zoneChoice) {
|
|
226
|
+
const zoneMap: Record<string, KeyboardZone> = {
|
|
227
|
+
"All Zones (Both Backlight & Underglow)": "all",
|
|
228
|
+
"Per-Key Backlight Matrix (Channel 3)": "matrix",
|
|
229
|
+
"Underglow / Side Strips (Channel 2)": "underglow",
|
|
230
|
+
};
|
|
231
|
+
const zone = zoneMap[zoneChoice] || "all";
|
|
232
|
+
sm.updateConfig({ zone });
|
|
233
|
+
writeKeyboardConfig(sm.getConfig());
|
|
234
|
+
ctx.ui.notify(`Active zone set to: ${zone.toUpperCase()}`, "info");
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (choice.startsWith("Set Brightness Scale")) {
|
|
240
|
+
const scaleOptions = ["100% Brightness", "75% Brightness", "50% Brightness", "25% Brightness"];
|
|
241
|
+
const scaleChoice = await ctx.ui.select("Select brightness scale:", scaleOptions);
|
|
242
|
+
if (scaleChoice) {
|
|
243
|
+
const scaleMap: Record<string, number> = {
|
|
244
|
+
"100% Brightness": 1.0,
|
|
245
|
+
"75% Brightness": 0.75,
|
|
246
|
+
"50% Brightness": 0.50,
|
|
247
|
+
"25% Brightness": 0.25,
|
|
248
|
+
};
|
|
249
|
+
const scale = scaleMap[scaleChoice] ?? 1.0;
|
|
250
|
+
sm.updateConfig({ brightnessScale: scale });
|
|
251
|
+
writeKeyboardConfig(sm.getConfig());
|
|
252
|
+
ctx.ui.notify(`Brightness scale set to ${Math.round(scale * 100)}%`, "info");
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (choice === "View Hardware Status") {
|
|
258
|
+
await showStatus(ctx, sm);
|
|
259
|
+
}
|
|
260
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { KeyboardZone } from "./types";
|
|
2
|
+
|
|
3
|
+
export const VIA_RAW_USAGE_PAGE = 0xff60;
|
|
4
|
+
export const VIA_RAW_USAGE = 0x0061;
|
|
5
|
+
|
|
6
|
+
export const CMD_GET_PROTOCOL_VERSION = 0x01;
|
|
7
|
+
export const CMD_CUSTOM_SET_VALUE = 0x07;
|
|
8
|
+
export const CMD_CUSTOM_GET_VALUE = 0x08;
|
|
9
|
+
export const CMD_CUSTOM_SAVE = 0x09;
|
|
10
|
+
|
|
11
|
+
export const CH_RGBLIGHT = 2; // Zone 1: Side strip / Underglow
|
|
12
|
+
export const CH_RGB_MATRIX = 3; // Zone 2: Per-key RGB Matrix
|
|
13
|
+
|
|
14
|
+
export const VAL_BRIGHTNESS = 1;
|
|
15
|
+
export const VAL_EFFECT = 2;
|
|
16
|
+
export const VAL_EFFECT_SPEED = 3;
|
|
17
|
+
export const VAL_COLOR = 4;
|
|
18
|
+
|
|
19
|
+
export function resolveChannels(zone: KeyboardZone): number[] {
|
|
20
|
+
switch (zone) {
|
|
21
|
+
case "underglow":
|
|
22
|
+
return [CH_RGBLIGHT];
|
|
23
|
+
case "matrix":
|
|
24
|
+
return [CH_RGB_MATRIX];
|
|
25
|
+
case "all":
|
|
26
|
+
default:
|
|
27
|
+
return [CH_RGBLIGHT, CH_RGB_MATRIX];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildPacket(payload: number[]): Uint8Array {
|
|
32
|
+
const packet = new Uint8Array(32);
|
|
33
|
+
for (let i = 0; i < payload.length && i < 32; i++) {
|
|
34
|
+
packet[i] = payload[i];
|
|
35
|
+
}
|
|
36
|
+
return packet;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildSetBrightnessPacket(channel: number, brightness: number): Uint8Array {
|
|
40
|
+
const val = Math.max(0, Math.min(255, Math.round(brightness)));
|
|
41
|
+
return buildPacket([CMD_CUSTOM_SET_VALUE, channel, VAL_BRIGHTNESS, val]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildSetEffectPacket(channel: number, effect: number): Uint8Array {
|
|
45
|
+
const val = Math.max(0, Math.min(255, Math.round(effect)));
|
|
46
|
+
return buildPacket([CMD_CUSTOM_SET_VALUE, channel, VAL_EFFECT, val]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildSetSpeedPacket(channel: number, speed: number): Uint8Array {
|
|
50
|
+
const val = Math.max(0, Math.min(255, Math.round(speed)));
|
|
51
|
+
return buildPacket([CMD_CUSTOM_SET_VALUE, channel, VAL_EFFECT_SPEED, val]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildSetColorPacket(channel: number, hue: number, sat: number): Uint8Array {
|
|
55
|
+
const h = Math.max(0, Math.min(255, Math.round(hue)));
|
|
56
|
+
const s = Math.max(0, Math.min(255, Math.round(sat)));
|
|
57
|
+
return buildPacket([CMD_CUSTOM_SET_VALUE, channel, VAL_COLOR, h, s]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildSavePacket(channel: number): Uint8Array {
|
|
61
|
+
return buildPacket([CMD_CUSTOM_SAVE, channel]);
|
|
62
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { applyKeyboardState } from "./driver";
|
|
2
|
+
import { hasOtherRunningSessions } from "./global-sessions";
|
|
3
|
+
import {
|
|
4
|
+
KEYBOARD_STATE_DEFINITIONS,
|
|
5
|
+
type KeyboardConfig,
|
|
6
|
+
type KeyboardState,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
export class KeyboardStateMachine {
|
|
10
|
+
private currentState: KeyboardState = "idle";
|
|
11
|
+
private isProcessing = false;
|
|
12
|
+
private hasUnreadChat = false;
|
|
13
|
+
private hasFatalError = false;
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private config: KeyboardConfig,
|
|
17
|
+
private checkOtherRunning: () => boolean = () => hasOtherRunningSessions(),
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
public getConfig(): KeyboardConfig {
|
|
21
|
+
return { ...this.config };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public updateConfig(newConfig: Partial<KeyboardConfig>): void {
|
|
25
|
+
this.config = { ...this.config, ...newConfig };
|
|
26
|
+
this.applyCurrentState(true);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public getCurrentState(): KeyboardState {
|
|
30
|
+
return this.currentState;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public isUnread(): boolean {
|
|
34
|
+
return this.hasUnreadChat;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public hasError(): boolean {
|
|
38
|
+
return this.hasFatalError;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public getStateLabel(state: KeyboardState = this.currentState): string {
|
|
42
|
+
return KEYBOARD_STATE_DEFINITIONS[state]?.labelZh ?? state;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public async transitionTo(nextState: KeyboardState, force = false): Promise<void> {
|
|
46
|
+
if (this.currentState === nextState && !force) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
this.currentState = nextState;
|
|
50
|
+
await this.applyCurrentState(force);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async applyCurrentState(force = false): Promise<void> {
|
|
54
|
+
await applyKeyboardState(this.currentState, this.config);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public async onSessionStart(): Promise<void> {
|
|
58
|
+
this.isProcessing = false;
|
|
59
|
+
this.hasUnreadChat = false;
|
|
60
|
+
this.hasFatalError = false;
|
|
61
|
+
|
|
62
|
+
if (this.checkOtherRunning()) {
|
|
63
|
+
await this.transitionTo("thinking");
|
|
64
|
+
} else {
|
|
65
|
+
await this.transitionTo("idle");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public async onAgentStart(): Promise<void> {
|
|
70
|
+
this.isProcessing = true;
|
|
71
|
+
this.hasUnreadChat = false;
|
|
72
|
+
this.hasFatalError = false;
|
|
73
|
+
await this.transitionTo("thinking");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public async onTurnStart(): Promise<void> {
|
|
77
|
+
this.isProcessing = true;
|
|
78
|
+
this.hasUnreadChat = false;
|
|
79
|
+
this.hasFatalError = false;
|
|
80
|
+
await this.transitionTo("thinking");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public async onToolCall(toolName: string, _input?: Record<string, unknown>): Promise<void> {
|
|
84
|
+
this.isProcessing = true;
|
|
85
|
+
const isInteractive =
|
|
86
|
+
toolName.includes("confirm") ||
|
|
87
|
+
toolName.includes("ask") ||
|
|
88
|
+
toolName.includes("prompt") ||
|
|
89
|
+
toolName === "question";
|
|
90
|
+
|
|
91
|
+
if (isInteractive) {
|
|
92
|
+
await this.transitionTo("need_approval");
|
|
93
|
+
} else {
|
|
94
|
+
await this.transitionTo("thinking");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public async onToolResult(): Promise<void> {
|
|
99
|
+
if (this.isProcessing) {
|
|
100
|
+
await this.transitionTo("thinking");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public async onProviderResponse(status?: number): Promise<void> {
|
|
105
|
+
if (typeof status === "number" && status >= 400) {
|
|
106
|
+
this.hasFatalError = true;
|
|
107
|
+
this.isProcessing = false;
|
|
108
|
+
this.hasUnreadChat = false;
|
|
109
|
+
await this.transitionTo("error");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public async onMessageEnd(stopReason?: string, errorMessage?: string): Promise<void> {
|
|
114
|
+
if (stopReason === "error" || stopReason === "aborted" || Boolean(errorMessage)) {
|
|
115
|
+
this.hasFatalError = true;
|
|
116
|
+
this.isProcessing = false;
|
|
117
|
+
this.hasUnreadChat = false;
|
|
118
|
+
await this.transitionTo("error");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public async onAgentSettled(hasError = false): Promise<void> {
|
|
123
|
+
this.isProcessing = false;
|
|
124
|
+
if (hasError || this.hasFatalError) {
|
|
125
|
+
this.hasFatalError = true;
|
|
126
|
+
this.hasUnreadChat = false;
|
|
127
|
+
await this.transitionTo("error");
|
|
128
|
+
} else {
|
|
129
|
+
this.hasUnreadChat = true;
|
|
130
|
+
await this.transitionTo("unread_chat");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public async onUserActivated(): Promise<void> {
|
|
135
|
+
if (!this.hasUnreadChat) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
this.hasUnreadChat = false;
|
|
139
|
+
|
|
140
|
+
if (this.hasFatalError) {
|
|
141
|
+
await this.transitionTo("error");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (this.isProcessing || this.checkOtherRunning()) {
|
|
146
|
+
await this.transitionTo("thinking");
|
|
147
|
+
} else {
|
|
148
|
+
await this.transitionTo("idle");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
public async onUserInput(): Promise<void> {
|
|
153
|
+
this.hasUnreadChat = false;
|
|
154
|
+
this.hasFatalError = false;
|
|
155
|
+
if (this.isProcessing || this.checkOtherRunning()) {
|
|
156
|
+
await this.transitionTo("thinking");
|
|
157
|
+
} else {
|
|
158
|
+
await this.transitionTo("idle");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
public async onError(): Promise<void> {
|
|
163
|
+
this.isProcessing = false;
|
|
164
|
+
this.hasFatalError = true;
|
|
165
|
+
this.hasUnreadChat = false;
|
|
166
|
+
await this.transitionTo("error");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
public async onShutdown(): Promise<void> {
|
|
170
|
+
if (this.checkOtherRunning()) {
|
|
171
|
+
await this.transitionTo("thinking");
|
|
172
|
+
} else {
|
|
173
|
+
await this.transitionTo("idle");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export type KeyboardState =
|
|
2
|
+
| "idle"
|
|
3
|
+
| "unread_chat"
|
|
4
|
+
| "thinking"
|
|
5
|
+
| "need_approval"
|
|
6
|
+
| "error";
|
|
7
|
+
|
|
8
|
+
export type KeyboardZone = "all" | "underglow" | "matrix";
|
|
9
|
+
|
|
10
|
+
export type KeyboardPattern = "breathing" | "blinking" | "solid";
|
|
11
|
+
|
|
12
|
+
export interface KeyboardStateDefinition {
|
|
13
|
+
state: KeyboardState;
|
|
14
|
+
label: string;
|
|
15
|
+
labelZh: string;
|
|
16
|
+
hue: number;
|
|
17
|
+
sat: number;
|
|
18
|
+
effect: number; // 1 = solid, 2 = breathing
|
|
19
|
+
speed: number;
|
|
20
|
+
brightness: number;
|
|
21
|
+
pattern: KeyboardPattern;
|
|
22
|
+
blinkCount?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface KeyboardConfig {
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
zone: KeyboardZone;
|
|
28
|
+
brightnessScale: number; // 0.1 to 1.0 (default 1.0)
|
|
29
|
+
saveToEeprom: boolean; // default false: strictly in-memory (--no-save)
|
|
30
|
+
cliPath?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface KeyboardDeviceInfo {
|
|
34
|
+
vendorId: string;
|
|
35
|
+
productId: string;
|
|
36
|
+
product?: string;
|
|
37
|
+
manufacturer?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const KEYBOARD_STATE_DEFINITIONS: Record<KeyboardState, KeyboardStateDefinition> = {
|
|
41
|
+
idle: {
|
|
42
|
+
state: "idle",
|
|
43
|
+
label: "White Breathing (Idle)",
|
|
44
|
+
labelZh: "白色 呼吸灯 (空闲待命)",
|
|
45
|
+
hue: 0,
|
|
46
|
+
sat: 0, // White
|
|
47
|
+
effect: 2, // Breathing
|
|
48
|
+
speed: 100,
|
|
49
|
+
brightness: 180,
|
|
50
|
+
pattern: "breathing",
|
|
51
|
+
},
|
|
52
|
+
unread_chat: {
|
|
53
|
+
state: "unread_chat",
|
|
54
|
+
label: "Green Breathing (Unread Chat)",
|
|
55
|
+
labelZh: "绿色 呼吸灯 (未读消息)",
|
|
56
|
+
hue: 85, // Green
|
|
57
|
+
sat: 255,
|
|
58
|
+
effect: 2, // Breathing
|
|
59
|
+
speed: 150,
|
|
60
|
+
brightness: 220,
|
|
61
|
+
pattern: "breathing",
|
|
62
|
+
},
|
|
63
|
+
thinking: {
|
|
64
|
+
state: "thinking",
|
|
65
|
+
label: "Blue Breathing (Thinking)",
|
|
66
|
+
labelZh: "蓝色 呼吸灯 (正在思考/执行)",
|
|
67
|
+
hue: 170, // Blue
|
|
68
|
+
sat: 255,
|
|
69
|
+
effect: 2, // Breathing
|
|
70
|
+
speed: 200,
|
|
71
|
+
brightness: 255,
|
|
72
|
+
pattern: "breathing",
|
|
73
|
+
},
|
|
74
|
+
need_approval: {
|
|
75
|
+
state: "need_approval",
|
|
76
|
+
label: "Yellow Blinking (Need Approval / Question)",
|
|
77
|
+
labelZh: "黄色 闪烁 (需要确认/提问)",
|
|
78
|
+
hue: 43, // Yellow
|
|
79
|
+
sat: 255,
|
|
80
|
+
effect: 1, // Alert / Fast pulse
|
|
81
|
+
speed: 255,
|
|
82
|
+
brightness: 255,
|
|
83
|
+
pattern: "blinking",
|
|
84
|
+
blinkCount: 4,
|
|
85
|
+
},
|
|
86
|
+
error: {
|
|
87
|
+
state: "error",
|
|
88
|
+
label: "Red Blinking (Error)",
|
|
89
|
+
labelZh: "红色 闪烁 (异常错误)",
|
|
90
|
+
hue: 0, // Red
|
|
91
|
+
sat: 255,
|
|
92
|
+
effect: 1, // Alert / Fast pulse
|
|
93
|
+
speed: 255,
|
|
94
|
+
brightness: 255,
|
|
95
|
+
pattern: "blinking",
|
|
96
|
+
blinkCount: 4,
|
|
97
|
+
},
|
|
98
|
+
};
|