clideck 1.25.5 → 1.25.7
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 +34 -32
- package/handlers.js +12 -1
- package/package.json +1 -1
- package/public/js/terminals.js +6 -4
- package/sessions.js +1 -0
- package/telemetry-receiver.js +29 -4
- package/transcript.js +56 -14
- package/workplan.txt +28 -0
package/README.md
CHANGED
|
@@ -1,79 +1,81 @@
|
|
|
1
|
-
|
|
1
|
+
<img src="public/img/clideck-logo-icon.png" width="48" alt="clideck logo">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# clideck
|
|
4
|
+
|
|
5
|
+
> **Formerly `termix-cli`** — if you arrived here from an old link, you're in the right place. The project has been renamed to **CliDeck**. Update your install: `npm install -g clideck`
|
|
6
|
+
|
|
7
|
+
Manage your AI agents like WhatsApp chats.
|
|
4
8
|
|
|
5
9
|
[Documentation](https://docs.clideck.dev/) | [Video Demo](https://youtu.be/hICrtjGAeDk) | [Website](https://clideck.dev/)
|
|
6
10
|
|
|
7
|
-

|
|
8
12
|
|
|
9
|
-
You
|
|
13
|
+
You run Claude Code, Codex, Gemini CLI in separate terminals. You alt-tab between them, forget which one finished, lose sessions when you close the lid.
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
clideck puts all your agents in one screen — a sidebar with every session, live status, last message preview, and timestamps. Click a session, you're in its terminal. Exactly like switching between chats.
|
|
16
|
+
|
|
17
|
+
Native terminals. Your keystrokes go straight to the agent, nothing in between. clideck never reads your prompts or output.
|
|
13
18
|
|
|
14
19
|
## Quick Start
|
|
15
20
|
|
|
16
21
|
```bash
|
|
17
|
-
|
|
18
|
-
clideck
|
|
22
|
+
npx clideck
|
|
19
23
|
```
|
|
20
24
|
|
|
21
25
|
Open [http://localhost:4000](http://localhost:4000). Click **+**, pick an agent, start working.
|
|
22
26
|
|
|
23
|
-
Or
|
|
27
|
+
Or install globally:
|
|
24
28
|
|
|
25
29
|
```bash
|
|
26
|
-
|
|
30
|
+
npm install -g clideck
|
|
31
|
+
clideck
|
|
27
32
|
```
|
|
28
33
|
|
|
29
34
|
## What You Get
|
|
30
35
|
|
|
31
|
-
- **Live status** — see which
|
|
32
|
-
- **Session resume** — close
|
|
33
|
-
- **Notifications** — browser and sound alerts
|
|
36
|
+
- **Live working/idle status** — see which agent is thinking and which is waiting for you, without checking each terminal
|
|
37
|
+
- **Session resume** — close clideck, reopen it tomorrow, pick up where you left off
|
|
38
|
+
- **Notifications** — browser and sound alerts when an agent finishes or needs input
|
|
34
39
|
- **Message previews** — latest output from each agent, right in the sidebar
|
|
35
40
|
- **Projects** — group sessions by project with drag-and-drop
|
|
36
41
|
- **Search** — find any session by name or scroll back through transcript content
|
|
37
|
-
- **Prompt Library** — save reusable prompts
|
|
38
|
-
- **Plugins** — ships with Voice Input and Trim Clip
|
|
39
|
-
- **15 themes** — dark and light
|
|
40
|
-
|
|
42
|
+
- **Prompt Library** — save reusable prompts, type `//` in any terminal to paste them
|
|
43
|
+
- **Plugins** — ships with Voice Input and Trim Clip, or build your own
|
|
44
|
+
- **15 themes** — dark and light, plus custom theme support
|
|
45
|
+
|
|
46
|
+
## Mobile Access
|
|
47
|
+
|
|
48
|
+
Check on your agents from your phone. Start a task, walk away, glance at your phone — see who's done, who's working, who needs input. Pair with one QR scan, no account needed. E2E encrypted — the relay cannot read your code.
|
|
41
49
|
|
|
42
50
|
## Supported Agents
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
clideck auto-detects whether each agent is working or idle:
|
|
45
53
|
|
|
46
54
|
| Agent | Status detection | Setup |
|
|
47
55
|
|-------|-----------------|-------|
|
|
48
56
|
| **Claude Code** | Automatic | Nothing to configure |
|
|
49
|
-
| **Codex** | Automatic | One-click setup in
|
|
50
|
-
| **Gemini CLI** | Automatic | One-click setup in
|
|
51
|
-
| **OpenCode** | Via plugin bridge | One-click setup in
|
|
57
|
+
| **Codex** | Automatic | One-click setup in clideck |
|
|
58
|
+
| **Gemini CLI** | Automatic | One-click setup in clideck |
|
|
59
|
+
| **OpenCode** | Via plugin bridge | One-click setup in clideck |
|
|
52
60
|
| **Shell** | I/O activity only | None |
|
|
53
61
|
|
|
54
|
-
Claude Code works out of the box. Other agents need a one-time
|
|
62
|
+
Claude Code works out of the box. Other agents need a one-time setup that clideck walks you through.
|
|
55
63
|
|
|
56
64
|
## How It Works
|
|
57
65
|
|
|
58
|
-
Each agent runs in a
|
|
59
|
-
|
|
60
|
-
OpenTelemetry runs locally between the agent and CliDeck. No data is collected, transmitted, or stored outside your machine.
|
|
61
|
-
|
|
62
|
-
## Prompt Library
|
|
63
|
-
|
|
64
|
-
Save prompts you use often and paste them into any terminal session instantly. Open the Prompts panel from the sidebar, click **+** to add a prompt with a name and text.
|
|
66
|
+
Each agent runs in a real terminal (PTY) on your machine. clideck receives lightweight status signals via OpenTelemetry — it knows *that* an agent is working, not *what* it's working on.
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
Everything runs locally. No data is collected, transmitted, or stored outside your machine.
|
|
67
69
|
|
|
68
70
|
## Platform Support
|
|
69
71
|
|
|
70
|
-
Tested on **macOS** and **Windows**. Works in any modern browser. Linux: untested — if you try it, open an issue
|
|
72
|
+
Tested on **macOS** and **Windows**. Works in any modern browser. Linux: untested — if you try it, open an issue.
|
|
71
73
|
|
|
72
74
|
## Documentation
|
|
73
75
|
|
|
74
76
|
Full setup guides, agent configuration, and plugin development:
|
|
75
77
|
|
|
76
|
-
**[
|
|
78
|
+
**[docs.clideck.dev](https://docs.clideck.dev/)**
|
|
77
79
|
|
|
78
80
|
## Acknowledgments
|
|
79
81
|
|
package/handlers.js
CHANGED
|
@@ -134,10 +134,21 @@ function onConnection(ws) {
|
|
|
134
134
|
plugins.notifyStatus(msg.id, !!msg.working);
|
|
135
135
|
}
|
|
136
136
|
break;
|
|
137
|
-
case 'terminal.buffer':
|
|
137
|
+
case 'terminal.buffer': {
|
|
138
138
|
require('./transcript').storeBuffer(msg.id, msg.lines);
|
|
139
139
|
sessions.broadcast({ type: 'screen.updated', id: msg.id });
|
|
140
|
+
const sess = sessions.getSessions().get(msg.id);
|
|
141
|
+
if (sess) {
|
|
142
|
+
const choices = require('./transcript').detectMenu(msg.lines, sess.presetId);
|
|
143
|
+
const key = choices ? JSON.stringify(choices) : '';
|
|
144
|
+
if (key !== (sess._menuKey || '')) {
|
|
145
|
+
sess._menuKey = key;
|
|
146
|
+
sessions.broadcast({ type: 'session.menu', id: msg.id, choices: choices || [] });
|
|
147
|
+
if (choices) sessions.broadcast({ type: 'session.status', id: msg.id, working: false, source: 'menu' });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
140
150
|
break;
|
|
151
|
+
}
|
|
141
152
|
case 'resize': sessions.resize(msg); break;
|
|
142
153
|
case 'rename': sessions.rename(msg); break;
|
|
143
154
|
case 'close': sessions.close(msg, cfg); break;
|
package/package.json
CHANGED
package/public/js/terminals.js
CHANGED
|
@@ -340,24 +340,26 @@ export function addTerminal(id, name, themeId, commandId, projectId, muted, last
|
|
|
340
340
|
|
|
341
341
|
// [SCREEN-CAPTURE] extract terminal buffer when BOTH idle AND render-silent (2s)
|
|
342
342
|
// Decoupled from status: telemetry knows when agent is done, onRender knows when terminal is done
|
|
343
|
+
const _telemetryOnly = cmd?.presetId === 'claude-code' || cmd?.presetId === 'codex' || cmd?.presetId === 'gemini-cli';
|
|
343
344
|
let _screenTimer = null, _renderSilent = false;
|
|
344
345
|
function _tryScreenCapture() {
|
|
345
346
|
const entry = state.terms.get(id);
|
|
346
|
-
if (!entry?.pendingScreenCapture || !_renderSilent || !entry.term) return;
|
|
347
|
+
if (!entry?.pendingScreenCapture || (!_renderSilent && !_telemetryOnly) || !entry.term) return;
|
|
347
348
|
entry.pendingScreenCapture = false;
|
|
348
349
|
const buf = entry.term.buffer.active;
|
|
349
350
|
const lines = [];
|
|
350
351
|
for (let i = 0; i < buf.length; i++) { const line = buf.getLine(i); if (line) lines.push(line.translateToString(true)); }
|
|
351
352
|
send({ type: 'terminal.buffer', id, lines });
|
|
352
353
|
}
|
|
354
|
+
let _idleTimer = null, _workTimer = null, _lastTyping = 0, _lastRender = 0;
|
|
355
|
+
term.onData(() => { _lastTyping = Date.now(); });
|
|
353
356
|
term.onRender(() => {
|
|
357
|
+
_lastRender = Date.now();
|
|
354
358
|
_renderSilent = false;
|
|
355
359
|
clearTimeout(_screenTimer);
|
|
356
360
|
_screenTimer = setTimeout(() => { _renderSilent = true; _tryScreenCapture(); }, 2000);
|
|
357
361
|
});
|
|
358
|
-
|
|
359
|
-
term.onData(() => { _lastTyping = Date.now(); });
|
|
360
|
-
term.onWriteParsed(() => { if (Date.now() - _lastTyping < 500) return; const entry = state.terms.get(id); if (entry) entry.lastRenderAt = Date.now(); if (!_workTimer) _workTimer = setTimeout(() => { _workTimer = null; setStatus(id, true); }, 1000); clearTimeout(_idleTimer); _idleTimer = setTimeout(() => { clearTimeout(_workTimer); _workTimer = null; setStatus(id, false); send({ type: 'session.statusReport', id, working: false }); }, 1500); });
|
|
362
|
+
term.onWriteParsed(() => { if (Date.now() - _lastTyping < 500) return; const entry = state.terms.get(id); if (entry) entry.lastRenderAt = Date.now(); if (_telemetryOnly) return; if (!_workTimer) _workTimer = setTimeout(() => { _workTimer = null; if (Date.now() - _lastRender < 500) setStatus(id, true); }, 1500); clearTimeout(_idleTimer); _idleTimer = setTimeout(() => { clearTimeout(_workTimer); _workTimer = null; setStatus(id, false); send({ type: 'session.statusReport', id, working: false }); }, 1500); });
|
|
361
363
|
|
|
362
364
|
// Expose capture function so setStatus can trigger it when idle arrives after render silence
|
|
363
365
|
setTimeout(() => { const e = state.terms.get(id); if (e) e.tryScreenCapture = _tryScreenCapture; }, 0);
|
package/sessions.js
CHANGED
|
@@ -282,6 +282,7 @@ function list() {
|
|
|
282
282
|
id, name: s.name, themeId: s.themeId, commandId: s.commandId, presetId: s.presetId || 'shell', projectId: s.projectId, muted: !!s.muted,
|
|
283
283
|
// Last preview text for sidebar display on reconnect
|
|
284
284
|
lastPreview: s.lastPreview || '', lastActivityAt: s.lastActivityAt || null,
|
|
285
|
+
menu: s._menuKey ? JSON.parse(s._menuKey) : undefined,
|
|
285
286
|
}));
|
|
286
287
|
}
|
|
287
288
|
|
package/telemetry-receiver.js
CHANGED
|
@@ -32,7 +32,7 @@ function handleLogs(req, res) {
|
|
|
32
32
|
const resAttrs = parseAttrs(rl.resource?.attributes);
|
|
33
33
|
const sessionId = resAttrs['clideck.session_id'];
|
|
34
34
|
|
|
35
|
-
//
|
|
35
|
+
// service.name values: claude-code, codex_cli_rs, gemini-cli
|
|
36
36
|
const serviceName = resAttrs['service.name'] || 'unknown';
|
|
37
37
|
let resolvedId = sessionId;
|
|
38
38
|
|
|
@@ -69,14 +69,39 @@ function handleLogs(req, res) {
|
|
|
69
69
|
const attrs = parseAttrs(lr.attributes);
|
|
70
70
|
|
|
71
71
|
const eventName = attrs['event.name'];
|
|
72
|
-
if (eventName) console.log(`[telemetry] ${
|
|
72
|
+
// if (serviceName === 'codex_cli_rs' && eventName) console.log(`[telemetry:codex] ${eventName}`);
|
|
73
|
+
// if (serviceName === 'gemini-cli' && eventName) console.log(`[telemetry:gemini] ${eventName}`);
|
|
73
74
|
|
|
74
|
-
// Telemetry
|
|
75
|
+
// Telemetry-based status
|
|
75
76
|
const startEvents = new Set(['user_prompt', 'gemini_cli.user_prompt', 'codex.user_prompt']);
|
|
76
|
-
|
|
77
77
|
if (startEvents.has(eventName)) {
|
|
78
78
|
broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
|
|
79
79
|
}
|
|
80
|
+
// Claude: telemetry-only status. user_prompt/any event → working, api_request → idle.
|
|
81
|
+
if (serviceName === 'claude-code' && eventName) {
|
|
82
|
+
if (eventName === 'api_request') {
|
|
83
|
+
broadcastFn?.({ type: 'session.status', id: resolvedId, working: false, source: 'telemetry' });
|
|
84
|
+
} else if (eventName !== 'user_prompt') {
|
|
85
|
+
broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Codex: telemetry-only status. codex.user_prompt/any event → working, codex.sse_event → idle.
|
|
89
|
+
if (serviceName === 'codex_cli_rs' && eventName) {
|
|
90
|
+
if (eventName === 'codex.sse_event') {
|
|
91
|
+
broadcastFn?.({ type: 'session.status', id: resolvedId, working: false, source: 'telemetry' });
|
|
92
|
+
} else if (eventName !== 'codex.user_prompt') {
|
|
93
|
+
broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Gemini: telemetry-only status. Whitelisted events → working, api_response (role=main) → idle.
|
|
97
|
+
if (serviceName === 'gemini-cli' && eventName) {
|
|
98
|
+
if (eventName === 'gemini_cli.api_response' && attrs['role'] === 'main') {
|
|
99
|
+
broadcastFn?.({ type: 'session.status', id: resolvedId, working: false, source: 'telemetry' });
|
|
100
|
+
} else if (eventName === 'gemini_cli.api_request' || eventName === 'gemini_cli.model_routing'
|
|
101
|
+
|| (eventName === 'gemini_cli.api_response' && attrs['role'] !== 'main')) {
|
|
102
|
+
broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
80
105
|
|
|
81
106
|
const agentSessionId = attrs['session.id'] || attrs['conversation.id'];
|
|
82
107
|
if (agentSessionId && sess) {
|
package/transcript.js
CHANGED
|
@@ -10,6 +10,7 @@ const inputBuf = {};
|
|
|
10
10
|
const outputBuf = {};
|
|
11
11
|
const cache = {};
|
|
12
12
|
const prefixes = {};
|
|
13
|
+
const userTexts = {}; // sessionId → [text, ...] — user prompts for parser matching
|
|
13
14
|
let broadcast = null;
|
|
14
15
|
let notifyPlugin = null;
|
|
15
16
|
|
|
@@ -71,7 +72,7 @@ function trackInput(id, data) {
|
|
|
71
72
|
}
|
|
72
73
|
if (ch === '\r' || ch === '\n') {
|
|
73
74
|
const line = buf.text.trim();
|
|
74
|
-
if (line) store(id, 'user', line);
|
|
75
|
+
if (line) { store(id, 'user', line); if (!userTexts[id]) userTexts[id] = []; userTexts[id].push(line); }
|
|
75
76
|
buf.text = '';
|
|
76
77
|
} else if (ch === '\x7f' || ch === '\x08') {
|
|
77
78
|
const chars = Array.from(buf.text);
|
|
@@ -127,14 +128,21 @@ function getScreen(id) {
|
|
|
127
128
|
|
|
128
129
|
// Per-agent screen parsers. Each returns [{role, text}] from .screen content.
|
|
129
130
|
const agentParsers = {
|
|
130
|
-
'claude-code': (lines) => {
|
|
131
|
+
'claude-code': (lines, id) => {
|
|
132
|
+
const known = userTexts[id]?.length ? new Set(userTexts[id]) : null;
|
|
131
133
|
const turns = [];
|
|
132
134
|
let current = null;
|
|
133
135
|
for (const line of lines) {
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
136
|
+
const agent = line.match(/^(?:[│ ]\s*)?[⏺•●]\s(.*)$/);
|
|
137
|
+
if (agent) {
|
|
136
138
|
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
137
|
-
current = { role:
|
|
139
|
+
current = { role: 'agent', text: agent[1] };
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const userM = line.match(/^(?:[│ ]\s*)?[❯›]\s(.*)$/);
|
|
143
|
+
if (userM && (known ? known.has(userM[1].trim()) : true)) {
|
|
144
|
+
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
145
|
+
current = { role: 'user', text: userM[1] };
|
|
138
146
|
continue;
|
|
139
147
|
}
|
|
140
148
|
if (!current) continue;
|
|
@@ -146,14 +154,21 @@ const agentParsers = {
|
|
|
146
154
|
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
147
155
|
return turns.length >= 2 ? turns : null;
|
|
148
156
|
},
|
|
149
|
-
'codex': (lines) => {
|
|
157
|
+
'codex': (lines, id) => {
|
|
158
|
+
const known = userTexts[id]?.length ? new Set(userTexts[id]) : null;
|
|
150
159
|
const turns = [];
|
|
151
160
|
let current = null;
|
|
152
161
|
for (const line of lines) {
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
162
|
+
const agent = line.match(/^(?:│\s*)?•\s(.*)$/);
|
|
163
|
+
if (agent) {
|
|
164
|
+
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
165
|
+
current = { role: 'agent', text: agent[1] };
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const userM = line.match(/^(?:│\s*)?›\s(.*)$/);
|
|
169
|
+
if (userM && (known ? known.has(userM[1].trim()) : true)) {
|
|
155
170
|
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
156
|
-
current = { role:
|
|
171
|
+
current = { role: 'user', text: userM[1] };
|
|
157
172
|
continue;
|
|
158
173
|
}
|
|
159
174
|
if (!current) continue;
|
|
@@ -165,7 +180,8 @@ const agentParsers = {
|
|
|
165
180
|
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
166
181
|
return turns.length >= 2 ? turns : null;
|
|
167
182
|
},
|
|
168
|
-
'gemini-cli': (lines) => {
|
|
183
|
+
'gemini-cli': (lines, id) => {
|
|
184
|
+
const known = userTexts[id]?.length ? new Set(userTexts[id]) : null;
|
|
169
185
|
const geminiChrome = t => {
|
|
170
186
|
const s = t.trim();
|
|
171
187
|
return /^shift\+tab to accept/i.test(s)
|
|
@@ -180,11 +196,12 @@ const agentParsers = {
|
|
|
180
196
|
let current = null;
|
|
181
197
|
for (const line of lines) {
|
|
182
198
|
if (geminiChrome(line)) continue;
|
|
183
|
-
const isUser = line.startsWith(' > ');
|
|
184
199
|
const isAgent = line.startsWith('✦ ');
|
|
200
|
+
const userM = line.startsWith(' > ') ? line.slice(3) : null;
|
|
201
|
+
const isUser = userM && (known ? known.has(userM.trim()) : true);
|
|
185
202
|
if (isUser || isAgent) {
|
|
186
203
|
if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
|
|
187
|
-
current = { role: isUser ? 'user' : 'agent', text: isUser ?
|
|
204
|
+
current = { role: isUser ? 'user' : 'agent', text: isUser ? userM : line.slice(2) };
|
|
188
205
|
continue;
|
|
189
206
|
}
|
|
190
207
|
if (!current) continue;
|
|
@@ -243,7 +260,7 @@ function getScreenTurns(id, agent) {
|
|
|
243
260
|
if (!screen) return null;
|
|
244
261
|
const lines = screen.split('\n');
|
|
245
262
|
const parser = agentParsers[agent];
|
|
246
|
-
const turns = parser ? parser(lines) : anchorParse(id, lines);
|
|
263
|
+
const turns = parser ? parser(lines, id) : anchorParse(id, lines);
|
|
247
264
|
// Drop trailing user turn — it's the empty prompt or unanswered input
|
|
248
265
|
if (turns?.length && turns[turns.length - 1].role === 'user') turns.pop();
|
|
249
266
|
return turns?.length >= 2 ? turns : null;
|
|
@@ -281,7 +298,32 @@ function clear(id) {
|
|
|
281
298
|
}
|
|
282
299
|
delete cache[id];
|
|
283
300
|
delete prefixes[id];
|
|
301
|
+
delete userTexts[id];
|
|
284
302
|
try { unlinkSync(join(DIR, `${id}.screen`)); } catch {}
|
|
285
303
|
}
|
|
286
304
|
|
|
287
|
-
|
|
305
|
+
// Detect interactive menus from raw screen lines. Returns [{value, label, selected}] or null.
|
|
306
|
+
// Finds the footer line, then walks upward collecting only the contiguous menu block.
|
|
307
|
+
const MENU_MARKERS = { 'claude-code': /[❯›]/, codex: /[›❯]/, 'gemini-cli': /●/ };
|
|
308
|
+
const MENU_CHOICE_RE = /^\s*(?:[│❯›●•]\s+)*(\d+)\.\s+(.+)$/;
|
|
309
|
+
function detectMenu(lines, presetId) {
|
|
310
|
+
const marker = MENU_MARKERS[presetId];
|
|
311
|
+
if (!marker) return null;
|
|
312
|
+
let footerIdx = -1;
|
|
313
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
314
|
+
if (/\besc\b|\(esc\)/i.test(lines[i])) { footerIdx = MENU_CHOICE_RE.test(lines[i]) ? i + 1 : i; break; }
|
|
315
|
+
}
|
|
316
|
+
if (footerIdx < 0) return null;
|
|
317
|
+
const choices = [];
|
|
318
|
+
for (let i = footerIdx - 1; i >= 0; i--) {
|
|
319
|
+
if (!lines[i].trim() || /^[│\s]+$/.test(lines[i])) continue;
|
|
320
|
+
const m = lines[i].match(MENU_CHOICE_RE);
|
|
321
|
+
if (!m) break;
|
|
322
|
+
if (choices.length && +m[1] >= +choices[0].value) break;
|
|
323
|
+
choices.unshift({ value: m[1], label: m[2].trim(), selected: marker.test(lines[i]) });
|
|
324
|
+
}
|
|
325
|
+
if (!choices.some(c => c.selected)) return null;
|
|
326
|
+
return choices.length ? choices : null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = { init, trackInput, trackOutput, storeBuffer, getScreen, getScreenTurns, getLastTurns, getCache, clear, setPrefix, detectMenu };
|
package/workplan.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
CliDeck — Future Tasks
|
|
2
|
+
======================
|
|
3
|
+
|
|
4
|
+
## Structured menu events
|
|
5
|
+
|
|
6
|
+
Move interactive menu detection from mobile-side parsing to CliDeck desktop.
|
|
7
|
+
Detect menus server-side, broadcast as structured data, render on both desktop and mobile.
|
|
8
|
+
|
|
9
|
+
Why:
|
|
10
|
+
- Single source of truth — detect once, consume everywhere
|
|
11
|
+
- Enables desktop sidebar preview ("please make a choice")
|
|
12
|
+
- Opens the door to raw ANSI-based detection (mobile never sees PTY bytes)
|
|
13
|
+
- Fixes stuck "working" status when agent shows a menu: no telemetry event fires for
|
|
14
|
+
"waiting for user input", so CliDeck never flips to idle and never sends the notification.
|
|
15
|
+
Menu detection can double as an idle signal — if a menu is detected, force status to idle.
|
|
16
|
+
|
|
17
|
+
Steps:
|
|
18
|
+
1. Add menu detection in CliDeck where screen parsing already happens
|
|
19
|
+
2. Store per-session menu state (choices, selected, footer)
|
|
20
|
+
3. Detect menu appearance and disappearance, broadcast session.menu events
|
|
21
|
+
4. Show "make a choice" preview in desktop session list
|
|
22
|
+
5. Daemon forwards session.menu to mobile
|
|
23
|
+
6. Remove mobile-side menu parsing, render from structured state instead
|
|
24
|
+
|
|
25
|
+
Notes:
|
|
26
|
+
- Detection heuristic stays the same (footer + numbered lines) unless ANSI-based approach is pursued
|
|
27
|
+
- Currently mobile detection works fine with regex fix + "Esc to cancel" footer check
|
|
28
|
+
- No agent-side telemetry event exists for menus — heuristic is the only option for now
|