pi-keyboard 0.1.0 → 0.1.2

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.
@@ -27,6 +27,7 @@ Feature: Pi Keyboard Lighting Indicator
27
27
  Scenario: User activates thread and marks message as read
28
28
  Given the keyboard is currently displaying unread chat state
29
29
  When the user activates or focuses the terminal window
30
+ And no other Pi sessions have unread messages
30
31
  And no other Pi sessions are running
31
32
  Then the unread status is cleared
32
33
  And the keyboard lighting transitions to white idle breathing
@@ -34,16 +35,31 @@ Feature: Pi Keyboard Lighting Indicator
34
35
  Scenario: User activates thread while another session is running
35
36
  Given the keyboard is currently displaying unread chat state
36
37
  When the user activates or focuses the terminal window
38
+ And no other Pi sessions have unread messages
37
39
  And another background Pi session is currently running
38
40
  Then the unread status is cleared
39
41
  And the keyboard lighting transitions to blue thinking breathing
40
42
 
43
+ Scenario: All sessions must be read for unread green light to clear
44
+ Given multiple Pi sessions exist across terminal tabs
45
+ And one session finishes generating an unread response
46
+ When the user activates one session but another session still has unread messages
47
+ Then the keyboard lighting remains in green unread chat breathing state
48
+ When all active sessions are read or activated
49
+ Then the keyboard lighting transitions out of unread chat to idle or thinking
50
+
51
+ Scenario: User manual abort does not trigger red error light
52
+ Given an agent turn is actively executing
53
+ When the user cancels or aborts the turn via Ctrl+C or abort
54
+ Then the keyboard lighting does not trigger red error alert
55
+ And the lighting state transitions cleanly back to idle or thinking
56
+
41
57
  Scenario: Pi transitions to thinking state with blue breathing light
42
58
  Given a user has submitted a prompt or the agent is executing a turn
43
59
  When the agent starts thinking or executing tools
44
60
  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
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
47
63
  And the update is applied with --no-save in memory
48
64
 
49
65
  Scenario: Pi transitions to need approval state with yellow blinking light
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-keyboard",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
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/via-rgb",
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
- // Set color, effect, speed, brightness in memory
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",
@@ -1,12 +1,14 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import type { KeyboardState } from "./types";
4
5
 
5
- export interface SessionRecord {
6
+ export interface SessionGlowRecord {
6
7
  sessionId: string;
7
8
  pid: number;
8
9
  cwd: string;
9
- status: "running" | "idle" | "settled" | "exited";
10
+ status: "running" | "idle" | "settled" | "error" | "need_approval" | "exited";
11
+ hasUnread: boolean;
10
12
  updatedAt: number;
11
13
  }
12
14
 
@@ -14,6 +16,15 @@ export function getRegistryDir(): string {
14
16
  return path.join(os.homedir(), ".pi", "agent", "directory-sessions");
15
17
  }
16
18
 
19
+ export function getSessionFileKey(cwd: string): string {
20
+ const normalized = path.resolve(cwd || process.cwd());
21
+ return `--${normalized.replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`;
22
+ }
23
+
24
+ export function getDirectoryRegistryPath(cwd: string): string {
25
+ return path.join(getRegistryDir(), getSessionFileKey(cwd));
26
+ }
27
+
17
28
  export function isProcessAlive(pid: number): boolean {
18
29
  if (!pid || pid <= 0) return false;
19
30
  try {
@@ -25,54 +36,186 @@ export function isProcessAlive(pid: number): boolean {
25
36
  }
26
37
 
27
38
  /**
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.
39
+ * Writes or updates the session's active glow and unread state in the registry.
30
40
  */
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
-
41
+ export function writeSessionGlowState(record: SessionGlowRecord): void {
38
42
  try {
39
- const dirEntries = fs.readdirSync(baseDir, { withFileTypes: true });
40
- for (const dirEntry of dirEntries) {
41
- if (!dirEntry.isDirectory()) continue;
43
+ const dir = getDirectoryRegistryPath(record.cwd);
44
+ if (!fs.existsSync(dir)) {
45
+ fs.mkdirSync(dir, { recursive: true });
46
+ }
47
+ const filePath = path.join(dir, `${record.sessionId}.json`);
48
+ const tmpPath = `${filePath}.tmp.${Date.now()}`;
42
49
 
43
- const subDirPath = path.join(baseDir, dirEntry.name);
44
- let sessionFiles: string[] = [];
50
+ // Merge with existing session data if present to avoid overwriting other fields
51
+ let merged: Record<string, unknown> = { ...record };
52
+ if (fs.existsSync(filePath)) {
45
53
  try {
46
- sessionFiles = fs.readdirSync(subDirPath);
54
+ const existing = JSON.parse(fs.readFileSync(filePath, "utf-8"));
55
+ if (existing && typeof existing === "object") {
56
+ merged = { ...existing, ...record };
57
+ }
47
58
  } catch {
48
- continue;
59
+ // use fresh record
49
60
  }
61
+ }
50
62
 
51
- for (const file of sessionFiles) {
52
- if (!file.endsWith(".json")) continue;
53
- const filePath = path.join(subDirPath, file);
63
+ fs.writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
64
+ fs.renameSync(tmpPath, filePath);
65
+ } catch {
66
+ // Best-effort write
67
+ }
68
+ }
54
69
 
70
+ /**
71
+ * Removes the session record upon clean shutdown.
72
+ */
73
+ export function removeSessionGlowState(cwd: string, sessionId: string): void {
74
+ try {
75
+ const filePath = path.join(getDirectoryRegistryPath(cwd), `${sessionId}.json`);
76
+ if (fs.existsSync(filePath)) {
77
+ fs.unlinkSync(filePath);
78
+ }
79
+ } catch {
80
+ // Best-effort remove
81
+ }
82
+ }
83
+
84
+ export interface GlobalStateSummary {
85
+ effectiveState: KeyboardState;
86
+ hasAnyUnread: boolean;
87
+ hasAnyRunning: boolean;
88
+ hasAnyError: boolean;
89
+ hasAnyNeedApproval: boolean;
90
+ activeSessionCount: number;
91
+ }
92
+
93
+ /**
94
+ * Reads all active sessions across all project directories, prunes dead PIDs,
95
+ * and determines the single authoritative global lighting state.
96
+ *
97
+ * Precedence:
98
+ * 1. Fatal Error (any session has fatal error) -> "error" (Red Blinking)
99
+ * 2. Need Approval (any session is waiting for user confirmation) -> "need_approval" (Yellow Blinking)
100
+ * 3. Unread Chat (ANY active session has unread messages) -> "unread_chat" (Green Breathing)
101
+ * 4. Thinking / Running (any session is actively executing) -> "thinking" (Blue Breathing)
102
+ * 5. Idle (all sessions are read and idle) -> "idle" (White Breathing)
103
+ */
104
+ export function evaluateGlobalLightingState(
105
+ selfSessionId?: string,
106
+ selfRecord?: Partial<SessionGlowRecord>,
107
+ ): GlobalStateSummary {
108
+ const baseDir = getRegistryDir();
109
+ const now = Date.now();
110
+ const maxStaleAgeMs = 5 * 60 * 1000; // 5 minutes
111
+
112
+ const allRecords: SessionGlowRecord[] = [];
113
+
114
+ if (fs.existsSync(baseDir)) {
115
+ try {
116
+ const dirEntries = fs.readdirSync(baseDir, { withFileTypes: true });
117
+ for (const dirEntry of dirEntries) {
118
+ if (!dirEntry.isDirectory()) continue;
119
+
120
+ const subDirPath = path.join(baseDir, dirEntry.name);
121
+ let sessionFiles: string[] = [];
55
122
  try {
56
- const content = fs.readFileSync(filePath, "utf-8");
57
- const record = JSON.parse(content) as SessionRecord;
123
+ sessionFiles = fs.readdirSync(subDirPath);
124
+ } catch {
125
+ continue;
126
+ }
127
+
128
+ for (const file of sessionFiles) {
129
+ if (!file.endsWith(".json")) continue;
130
+ const filePath = path.join(subDirPath, file);
58
131
 
59
- if (!record || typeof record !== "object") continue;
60
- if (record.pid === excludePid) continue;
132
+ try {
133
+ const content = fs.readFileSync(filePath, "utf-8");
134
+ const record = JSON.parse(content) as SessionGlowRecord;
61
135
 
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;
136
+ if (!record || typeof record !== "object") continue;
137
+
138
+ // Prune dead process IDs
139
+ if (!isProcessAlive(record.pid)) {
140
+ try {
141
+ fs.unlinkSync(filePath);
142
+ } catch {}
143
+ continue;
144
+ }
145
+
146
+ // Skip stale records
147
+ if (now - (record.updatedAt || 0) > maxStaleAgeMs) {
148
+ continue;
66
149
  }
150
+
151
+ // If this is self, use the latest in-memory selfRecord values
152
+ if (selfSessionId && record.sessionId === selfSessionId && selfRecord) {
153
+ allRecords.push({ ...record, ...selfRecord } as SessionGlowRecord);
154
+ } else {
155
+ allRecords.push(record);
156
+ }
157
+ } catch {
158
+ // Ignore unreadable files
67
159
  }
68
- } catch {
69
- // Ignore unreadable or corrupt temp files
70
160
  }
71
161
  }
162
+ } catch {
163
+ // Ignore base scan errors
72
164
  }
73
- } catch {
74
- return false;
75
165
  }
76
166
 
77
- return false;
167
+ // Ensure self record is included if not found on disk yet
168
+ if (selfSessionId && selfRecord && !allRecords.some((r) => r.sessionId === selfSessionId)) {
169
+ allRecords.push({
170
+ sessionId: selfSessionId,
171
+ pid: process.pid,
172
+ cwd: process.cwd(),
173
+ status: "idle",
174
+ hasUnread: false,
175
+ updatedAt: now,
176
+ ...selfRecord,
177
+ } as SessionGlowRecord);
178
+ }
179
+
180
+ let hasAnyError = false;
181
+ let hasAnyNeedApproval = false;
182
+ let hasAnyUnread = false;
183
+ let hasAnyRunning = false;
184
+
185
+ for (const r of allRecords) {
186
+ if (r.status === "error") {
187
+ hasAnyError = true;
188
+ }
189
+ if (r.status === "need_approval") {
190
+ hasAnyNeedApproval = true;
191
+ }
192
+ if (r.hasUnread) {
193
+ hasAnyUnread = true;
194
+ }
195
+ if (r.status === "running") {
196
+ hasAnyRunning = true;
197
+ }
198
+ }
199
+
200
+ let effectiveState: KeyboardState = "idle";
201
+ if (hasAnyError) {
202
+ effectiveState = "error";
203
+ } else if (hasAnyNeedApproval) {
204
+ effectiveState = "need_approval";
205
+ } else if (hasAnyUnread) {
206
+ effectiveState = "unread_chat";
207
+ } else if (hasAnyRunning) {
208
+ effectiveState = "thinking";
209
+ } else {
210
+ effectiveState = "idle";
211
+ }
212
+
213
+ return {
214
+ effectiveState,
215
+ hasAnyUnread,
216
+ hasAnyRunning,
217
+ hasAnyError,
218
+ hasAnyNeedApproval,
219
+ activeSessionCount: allRecords.length,
220
+ };
78
221
  }
package/src/index.ts CHANGED
@@ -25,19 +25,33 @@ function getStateMachine(): KeyboardStateMachine {
25
25
  export default function (pi: ExtensionAPI): void {
26
26
  const sm = getStateMachine();
27
27
  let terminalInputDetached: (() => void) | undefined;
28
+ let rawStdinListener: ((chunk: Buffer | string) => void) | undefined;
28
29
 
29
30
  // Pi Lifecycle Hooks
30
31
  pi.on("session_start", async (_event, ctx) => {
31
- // Attach passive terminal input / focus observer to detect thread activation
32
+ const sessionId = (ctx as { sessionManager?: { getSessionId?: () => string } }).sessionManager?.getSessionId?.();
33
+ sm.setSessionContext(sessionId, ctx.cwd);
34
+
35
+ // Passive terminal input observer via extension UI context
32
36
  if (ctx.hasUI && typeof ctx.ui.onTerminalInput === "function" && !terminalInputDetached) {
33
37
  terminalInputDetached = ctx.ui.onTerminalInput((_data: string) => {
34
- if (sm.isUnread()) {
38
+ if (sm.isUnread() || sm.hasError()) {
35
39
  void sm.onUserActivated();
36
40
  }
37
41
  return undefined; // Passive observer: never consume or block input
38
42
  });
39
43
  }
40
44
 
45
+ // Direct raw stdin focus / keypress observer for reliable thread activation
46
+ if (process.stdin.isTTY && !rawStdinListener) {
47
+ rawStdinListener = (_chunk: Buffer | string) => {
48
+ if (sm.isUnread() || sm.hasError()) {
49
+ void sm.onUserActivated();
50
+ }
51
+ };
52
+ process.stdin.on("data", rawStdinListener);
53
+ }
54
+
41
55
  await sm.onSessionStart();
42
56
  });
43
57
 
@@ -67,14 +81,18 @@ export default function (pi: ExtensionAPI): void {
67
81
 
68
82
  pi.on("turn_end", async (event, _ctx) => {
69
83
  const msg = event.message as { stopReason?: string; errorMessage?: string } | undefined;
70
- if (msg?.stopReason === "error" || msg?.stopReason === "aborted" || Boolean(msg?.errorMessage)) {
84
+ if (msg?.stopReason === "aborted") {
85
+ await sm.onMessageEnd("aborted");
86
+ } else if (msg?.stopReason === "error" || Boolean(msg?.errorMessage)) {
71
87
  await sm.onMessageEnd(msg?.stopReason, msg?.errorMessage);
72
88
  }
73
89
  });
74
90
 
75
91
  pi.on("message_end", async (event, _ctx) => {
76
92
  const msg = event.message as { stopReason?: string; errorMessage?: string } | undefined;
77
- if (msg?.stopReason === "error" || msg?.stopReason === "aborted" || Boolean(msg?.errorMessage)) {
93
+ if (msg?.stopReason === "aborted") {
94
+ await sm.onMessageEnd("aborted");
95
+ } else if (msg?.stopReason === "error" || Boolean(msg?.errorMessage)) {
78
96
  await sm.onMessageEnd(msg?.stopReason, msg?.errorMessage);
79
97
  }
80
98
  });
@@ -89,7 +107,9 @@ export default function (pi: ExtensionAPI): void {
89
107
  const entry = branch[i];
90
108
  if (entry && entry.type === "message" && entry.message && entry.message.role === "assistant") {
91
109
  const msg = entry.message as { stopReason?: string; errorMessage?: string };
92
- if (msg.stopReason === "error" || msg.stopReason === "aborted" || Boolean(msg.errorMessage)) {
110
+ if (msg.stopReason === "aborted") {
111
+ hasError = false;
112
+ } else if (msg.stopReason === "error" || Boolean(msg.errorMessage)) {
93
113
  hasError = true;
94
114
  }
95
115
  break;
@@ -107,6 +127,12 @@ export default function (pi: ExtensionAPI): void {
107
127
  pi.on("session_shutdown", async (_event, _ctx) => {
108
128
  terminalInputDetached?.();
109
129
  terminalInputDetached = undefined;
130
+
131
+ if (rawStdinListener) {
132
+ process.stdin.off("data", rawStdinListener);
133
+ rawStdinListener = undefined;
134
+ }
135
+
110
136
  await sm.onShutdown();
111
137
  });
112
138
 
@@ -114,6 +140,9 @@ export default function (pi: ExtensionAPI): void {
114
140
  pi.registerCommand("keyboard", {
115
141
  description: "Manage VIA/QMK keyboard RGB lighting status indicators",
116
142
  handler: async (args: string, ctx: ExtensionCommandContext) => {
143
+ // Executing a command in the session confirms user activation
144
+ void sm.onUserActivated();
145
+
117
146
  const trimmed = args.trim().toLowerCase();
118
147
 
119
148
  if (trimmed === "on") {
@@ -1,5 +1,12 @@
1
+ import crypto from "node:crypto";
1
2
  import { applyKeyboardState } from "./driver";
2
- import { hasOtherRunningSessions } from "./global-sessions";
3
+ import {
4
+ evaluateGlobalLightingState,
5
+ removeSessionGlowState,
6
+ writeSessionGlowState,
7
+ type GlobalStateSummary,
8
+ type SessionGlowRecord,
9
+ } from "./global-sessions";
3
10
  import {
4
11
  KEYBOARD_STATE_DEFINITIONS,
5
12
  type KeyboardConfig,
@@ -11,11 +18,32 @@ export class KeyboardStateMachine {
11
18
  private isProcessing = false;
12
19
  private hasUnreadChat = false;
13
20
  private hasFatalError = false;
21
+ private sessionId: string;
22
+ private cwd: string;
14
23
 
15
24
  constructor(
16
25
  private config: KeyboardConfig,
17
- private checkOtherRunning: () => boolean = () => hasOtherRunningSessions(),
18
- ) {}
26
+ sessionId?: string,
27
+ cwd?: string,
28
+ private evalGlobalState: (id: string, record: SessionGlowRecord) => GlobalStateSummary = (id, rec) =>
29
+ evaluateGlobalLightingState(id, rec),
30
+ ) {
31
+ this.sessionId = sessionId || crypto.randomUUID();
32
+ this.cwd = cwd || process.cwd();
33
+ }
34
+
35
+ public getSessionId(): string {
36
+ return this.sessionId;
37
+ }
38
+
39
+ public getCwd(): string {
40
+ return this.cwd;
41
+ }
42
+
43
+ public setSessionContext(sessionId?: string, cwd?: string): void {
44
+ if (sessionId) this.sessionId = sessionId;
45
+ if (cwd) this.cwd = cwd;
46
+ }
19
47
 
20
48
  public getConfig(): KeyboardConfig {
21
49
  return { ...this.config };
@@ -42,6 +70,34 @@ export class KeyboardStateMachine {
42
70
  return KEYBOARD_STATE_DEFINITIONS[state]?.labelZh ?? state;
43
71
  }
44
72
 
73
+ private buildSelfRecord(statusOverride?: SessionGlowRecord["status"]): SessionGlowRecord {
74
+ let status: SessionGlowRecord["status"] = "idle";
75
+ if (this.hasFatalError) {
76
+ status = "error";
77
+ } else if (this.isProcessing) {
78
+ status = "running";
79
+ } else if (this.hasUnreadChat) {
80
+ status = "settled";
81
+ }
82
+
83
+ return {
84
+ sessionId: this.sessionId,
85
+ pid: process.pid,
86
+ cwd: this.cwd,
87
+ status: statusOverride ?? status,
88
+ hasUnread: this.hasUnreadChat,
89
+ updatedAt: Date.now(),
90
+ };
91
+ }
92
+
93
+ private async syncAndEvaluate(statusOverride?: SessionGlowRecord["status"]): Promise<void> {
94
+ const record = this.buildSelfRecord(statusOverride);
95
+ writeSessionGlowState(record);
96
+
97
+ const summary = this.evalGlobalState(this.sessionId, record);
98
+ await this.transitionTo(summary.effectiveState);
99
+ }
100
+
45
101
  public async transitionTo(nextState: KeyboardState, force = false): Promise<void> {
46
102
  if (this.currentState === nextState && !force) {
47
103
  return;
@@ -58,26 +114,21 @@ export class KeyboardStateMachine {
58
114
  this.isProcessing = false;
59
115
  this.hasUnreadChat = false;
60
116
  this.hasFatalError = false;
61
-
62
- if (this.checkOtherRunning()) {
63
- await this.transitionTo("thinking");
64
- } else {
65
- await this.transitionTo("idle");
66
- }
117
+ await this.syncAndEvaluate("idle");
67
118
  }
68
119
 
69
120
  public async onAgentStart(): Promise<void> {
70
121
  this.isProcessing = true;
71
122
  this.hasUnreadChat = false;
72
123
  this.hasFatalError = false;
73
- await this.transitionTo("thinking");
124
+ await this.syncAndEvaluate("running");
74
125
  }
75
126
 
76
127
  public async onTurnStart(): Promise<void> {
77
128
  this.isProcessing = true;
78
129
  this.hasUnreadChat = false;
79
130
  this.hasFatalError = false;
80
- await this.transitionTo("thinking");
131
+ await this.syncAndEvaluate("running");
81
132
  }
82
133
 
83
134
  public async onToolCall(toolName: string, _input?: Record<string, unknown>): Promise<void> {
@@ -89,15 +140,15 @@ export class KeyboardStateMachine {
89
140
  toolName === "question";
90
141
 
91
142
  if (isInteractive) {
92
- await this.transitionTo("need_approval");
143
+ await this.syncAndEvaluate("need_approval");
93
144
  } else {
94
- await this.transitionTo("thinking");
145
+ await this.syncAndEvaluate("running");
95
146
  }
96
147
  }
97
148
 
98
149
  public async onToolResult(): Promise<void> {
99
150
  if (this.isProcessing) {
100
- await this.transitionTo("thinking");
151
+ await this.syncAndEvaluate("running");
101
152
  }
102
153
  }
103
154
 
@@ -106,16 +157,25 @@ export class KeyboardStateMachine {
106
157
  this.hasFatalError = true;
107
158
  this.isProcessing = false;
108
159
  this.hasUnreadChat = false;
109
- await this.transitionTo("error");
160
+ await this.syncAndEvaluate("error");
110
161
  }
111
162
  }
112
163
 
113
164
  public async onMessageEnd(stopReason?: string, errorMessage?: string): Promise<void> {
114
- if (stopReason === "error" || stopReason === "aborted" || Boolean(errorMessage)) {
165
+ if (stopReason === "aborted") {
166
+ // User manually stopped/cancelled session — NOT an error!
167
+ this.isProcessing = false;
168
+ this.hasFatalError = false;
169
+ this.hasUnreadChat = false;
170
+ await this.syncAndEvaluate("idle");
171
+ return;
172
+ }
173
+
174
+ if (stopReason === "error" || Boolean(errorMessage)) {
115
175
  this.hasFatalError = true;
116
176
  this.isProcessing = false;
117
177
  this.hasUnreadChat = false;
118
- await this.transitionTo("error");
178
+ await this.syncAndEvaluate("error");
119
179
  }
120
180
  }
121
181
 
@@ -124,53 +184,44 @@ export class KeyboardStateMachine {
124
184
  if (hasError || this.hasFatalError) {
125
185
  this.hasFatalError = true;
126
186
  this.hasUnreadChat = false;
127
- await this.transitionTo("error");
187
+ await this.syncAndEvaluate("error");
128
188
  } else {
189
+ this.hasFatalError = false;
129
190
  this.hasUnreadChat = true;
130
- await this.transitionTo("unread_chat");
191
+ await this.syncAndEvaluate("settled");
131
192
  }
132
193
  }
133
194
 
195
+ /**
196
+ * Called when user focuses the terminal window or interacts (key press / FocusIn).
197
+ * Marks current session as read and re-evaluates global system status:
198
+ * - If any OTHER session still has unread messages -> stays in unread_chat (green)
199
+ * - Else if any session is running -> thinking (blue)
200
+ * - Else -> idle (white)
201
+ */
134
202
  public async onUserActivated(): Promise<void> {
135
- if (!this.hasUnreadChat) {
136
- return;
137
- }
138
203
  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
- }
204
+ this.hasFatalError = false;
205
+ await this.syncAndEvaluate(this.isProcessing ? "running" : "idle");
150
206
  }
151
207
 
152
208
  public async onUserInput(): Promise<void> {
153
209
  this.hasUnreadChat = false;
154
210
  this.hasFatalError = false;
155
- if (this.isProcessing || this.checkOtherRunning()) {
156
- await this.transitionTo("thinking");
157
- } else {
158
- await this.transitionTo("idle");
159
- }
211
+ this.isProcessing = true;
212
+ await this.syncAndEvaluate("running");
160
213
  }
161
214
 
162
215
  public async onError(): Promise<void> {
163
216
  this.isProcessing = false;
164
217
  this.hasFatalError = true;
165
218
  this.hasUnreadChat = false;
166
- await this.transitionTo("error");
219
+ await this.syncAndEvaluate("error");
167
220
  }
168
221
 
169
222
  public async onShutdown(): Promise<void> {
170
- if (this.checkOtherRunning()) {
171
- await this.transitionTo("thinking");
172
- } else {
173
- await this.transitionTo("idle");
174
- }
223
+ removeSessionGlowState(this.cwd, this.sessionId);
224
+ const summary = evaluateGlobalLightingState();
225
+ await this.transitionTo(summary.effectiveState);
175
226
  }
176
227
  }
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: 100,
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: 255,
58
+ sat: 240,
58
59
  effect: 2, // Breathing
59
- speed: 150,
60
- brightness: 220,
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: 170, // Blue
68
- sat: 255,
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: 255,
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",