@indianic/dialout 1.8.0 → 1.9.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/dist/ai-sessions.d.ts +14 -1
- package/dist/ai-sessions.d.ts.map +1 -1
- package/dist/ai-sessions.js +76 -5
- package/dist/ai-sessions.js.map +1 -1
- package/dist/tmux-manager.d.ts +1 -0
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +11 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/websocket.js +6 -0
- package/dist/websocket.js.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/event-merge.d.ts +7 -0
- package/node_modules/@indianic/dialout-shared/dist/event-merge.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/index.d.ts +3 -1
- package/node_modules/@indianic/dialout-shared/dist/index.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/index.js +7 -1
- package/node_modules/@indianic/dialout-shared/dist/notify.d.ts +7 -0
- package/node_modules/@indianic/dialout-shared/dist/notify.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/notify.js +16 -0
- package/node_modules/@indianic/dialout-shared/dist/pane-prompt.d.ts +46 -0
- package/node_modules/@indianic/dialout-shared/dist/pane-prompt.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/pane-prompt.js +227 -0
- package/node_modules/@indianic/dialout-shared/dist/session.d.ts +7 -0
- package/node_modules/@indianic/dialout-shared/dist/session.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/package.json +1 -1
- package/node_modules/@indianic/dialout-shared/src/.omc/state/sessions/ebad4a54-74e6-4430-8f1c-4e4268aa5d4d/pre-tool-advisory-throttle.json +10 -0
- package/node_modules/@indianic/dialout-shared/src/event-merge.ts +7 -0
- package/node_modules/@indianic/dialout-shared/src/index.ts +4 -1
- package/node_modules/@indianic/dialout-shared/src/notify.test.ts +12 -1
- package/node_modules/@indianic/dialout-shared/src/notify.ts +18 -0
- package/node_modules/@indianic/dialout-shared/src/pane-prompt.test.ts +179 -0
- package/node_modules/@indianic/dialout-shared/src/pane-prompt.ts +218 -0
- package/node_modules/@indianic/dialout-shared/src/session.ts +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface PanePromptOption {
|
|
2
|
+
/** 1-based position in the menu. */
|
|
3
|
+
n: number;
|
|
4
|
+
label: string;
|
|
5
|
+
/** Indented line(s) under the option: its explanation. */
|
|
6
|
+
hint?: string;
|
|
7
|
+
/** The row the cursor is on. */
|
|
8
|
+
selected: boolean;
|
|
9
|
+
/** Multi-select rows: ticked or not. null for an ordinary row. */
|
|
10
|
+
checked: boolean | null;
|
|
11
|
+
}
|
|
12
|
+
export interface PanePrompt {
|
|
13
|
+
/** Changes when the question or its choices change (not when the cursor moves). */
|
|
14
|
+
id: string;
|
|
15
|
+
/** Context above the question: "Bash command", the command itself, a file name. */
|
|
16
|
+
detail: string[];
|
|
17
|
+
question: string;
|
|
18
|
+
options: PanePromptOption[];
|
|
19
|
+
/** Some rows are checkboxes: a tap toggles rather than answers. */
|
|
20
|
+
multi: boolean;
|
|
21
|
+
/** The menu's own key hint, shown small under the choices. */
|
|
22
|
+
footer: string;
|
|
23
|
+
/** Approval-shaped (run/edit/trust) rather than a question to answer. */
|
|
24
|
+
approval: boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The prompt on screen, or null. Looks only at the bottom of the pane (a
|
|
28
|
+
* prompt is always the last thing drawn) and demands a cursor-marked list:
|
|
29
|
+
* either numbered from 1 with the cursor on a row, or any list followed by a
|
|
30
|
+
* key-hint footer. A shell prompt ("❯ ") or a numbered list in the agent's
|
|
31
|
+
* reply has neither, which is what keeps false positives out.
|
|
32
|
+
*/
|
|
33
|
+
export declare function detectPanePrompt(pane: string): PanePrompt | null;
|
|
34
|
+
/** Key names the agent may send to a pane, besides single characters. */
|
|
35
|
+
export declare const PROMPT_KEYS: readonly ["Up", "Down", "Left", "Right", "Enter", "Escape", "Tab", "BTab", "Space", "BSpace", "C-c"];
|
|
36
|
+
export type PromptKey = (typeof PROMPT_KEYS)[number];
|
|
37
|
+
/** A key list is safe to hand to `tmux send-keys`: named keys or one printable character each. */
|
|
38
|
+
export declare function isSafeKeyList(keys: unknown): keys is string[];
|
|
39
|
+
/**
|
|
40
|
+
* Keys that choose option `n` (1-based): move the cursor from where it is to
|
|
41
|
+
* that row with arrows, then Enter. Arrows rather than the digit shortcut
|
|
42
|
+
* because every menu honours arrows, and only some honour digits. On a
|
|
43
|
+
* multi-select row Enter toggles it; the menu stays up and is re-read.
|
|
44
|
+
*/
|
|
45
|
+
export declare function promptKeys(p: PanePrompt, n: number): string[];
|
|
46
|
+
//# sourceMappingURL=pane-prompt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pane-prompt.d.ts","sourceRoot":"","sources":["../src/pane-prompt.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,gBAAgB;IAC/B,oCAAoC;IACpC,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,0DAA0D;IAC1D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gCAAgC;IAChC,QAAQ,EAAE,OAAO,CAAC;IAClB,kEAAkE;IAClE,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,UAAU;IACzB,mFAAmF;IACnF,EAAE,EAAE,MAAM,CAAC;IACX,mFAAmF;IACnF,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,gBAAgB,EAAE,CAAC;IAC5B,mEAAmE;IACnE,KAAK,EAAE,OAAO,CAAC;IACf,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,QAAQ,EAAE,OAAO,CAAC;CACnB;AA6GD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAuChE;AAED,yEAAyE;AACzE,eAAO,MAAM,WAAW,sGAAuG,CAAC;AAChI,MAAM,MAAM,SAAS,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAErD,kGAAkG;AAClG,wBAAgB,aAAa,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,IAAI,MAAM,EAAE,CAG7D;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAK7D"}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// A coding agent waiting on the user, read from what its TUI is showing.
|
|
3
|
+
//
|
|
4
|
+
// Permission prompts, plan approvals, trust prompts and question menus are
|
|
5
|
+
// drawn in the terminal and most never reach the transcript, so a chat built
|
|
6
|
+
// from the transcript alone looks stuck while the agent is asking something.
|
|
7
|
+
// The agent captures the pane and runs it through detectPanePrompt; clients
|
|
8
|
+
// render the result as tappable choices and answer with promptKeys.
|
|
9
|
+
//
|
|
10
|
+
// One parser for every CLI on purpose: Claude Code, Codex and Grok all draw
|
|
11
|
+
// the same shape — a question, then a list with a cursor on one row
|
|
12
|
+
// (❯ / › / >), usually numbered, then a footer like "Enter to confirm · Esc to
|
|
13
|
+
// cancel". Recognising the shape rather than each vendor's wording is what
|
|
14
|
+
// keeps this working across their releases.
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.PROMPT_KEYS = void 0;
|
|
17
|
+
exports.detectPanePrompt = detectPanePrompt;
|
|
18
|
+
exports.isSafeKeyList = isSafeKeyList;
|
|
19
|
+
exports.promptKeys = promptKeys;
|
|
20
|
+
// Box-drawing borders some CLIs draw around a prompt: stripped before parsing,
|
|
21
|
+
// keeping the indentation inside the box (it tells options from their hints).
|
|
22
|
+
const BORDER_LEFT = /^\s*[│┃║] ?/u;
|
|
23
|
+
const BORDER_RIGHT = /\s*[│┃║]\s*$/u;
|
|
24
|
+
/** Lines under a prompt that may still belong to the TUI (status lines). */
|
|
25
|
+
const TRAILING = 14;
|
|
26
|
+
const SEPARATOR = /^[\s─━═╌┄┈\-_╭╮╰╯┌┐└┘╔╗╚╝]{4,}$/u;
|
|
27
|
+
const FOOTER = /(enter to (confirm|select|submit)|esc to (cancel|exit|interrupt)|to navigate|press enter|↑\/↓|tab\/arrow|y\/n)/i;
|
|
28
|
+
const CHECK_ON = /^\[(x|✔|✓|■|●)\]|^[◉☑✔✓■]/iu;
|
|
29
|
+
const CHECK_OFF = /^\[ \]|^[◯○☐□]/u;
|
|
30
|
+
const APPROVAL = /\b(proceed|allow|approve|permission|trust|run|execute|edit|create|delete|make this edit|overwrite|apply)\b/i;
|
|
31
|
+
// "❯ 1. Yes" " 2. No (esc)" "› 1. Yes, proceed (y)"
|
|
32
|
+
const NUMBERED = /^(\s*)([❯›>▶→➜●])?\s*(\d{1,2})[.)]\s+(.+?)\s*$/u;
|
|
33
|
+
// "❯ Yes, I trust this folder" / " No, exit" — unnumbered rows
|
|
34
|
+
const BARE = /^(\s*)(?:([❯›>▶→➜●])\s+)?(\S.*?)\s*$/u;
|
|
35
|
+
function clean(line) {
|
|
36
|
+
return line.replace(BORDER_LEFT, '').replace(BORDER_RIGHT, '').replace(/\s+$/, '');
|
|
37
|
+
}
|
|
38
|
+
function hash(s) {
|
|
39
|
+
let h = 0x811c9dc5;
|
|
40
|
+
for (let i = 0; i < s.length; i++) {
|
|
41
|
+
h ^= s.charCodeAt(i);
|
|
42
|
+
h = Math.imul(h, 0x01000193);
|
|
43
|
+
}
|
|
44
|
+
return (h >>> 0).toString(36);
|
|
45
|
+
}
|
|
46
|
+
function checkState(label) {
|
|
47
|
+
if (CHECK_ON.test(label))
|
|
48
|
+
return { label: label.replace(CHECK_ON, '').trim(), checked: true };
|
|
49
|
+
if (CHECK_OFF.test(label))
|
|
50
|
+
return { label: label.replace(CHECK_OFF, '').trim(), checked: false };
|
|
51
|
+
return { label, checked: null };
|
|
52
|
+
}
|
|
53
|
+
/** Pull the question and its context from the lines above the menu. */
|
|
54
|
+
function above(lines, end) {
|
|
55
|
+
const block = [];
|
|
56
|
+
for (let i = end - 1; i >= 0 && block.length < 8; i--) {
|
|
57
|
+
const l = lines[i].trim();
|
|
58
|
+
if (SEPARATOR.test(l))
|
|
59
|
+
break;
|
|
60
|
+
if (!l) {
|
|
61
|
+
if (block.length) {
|
|
62
|
+
const prev = lines[i - 1]?.trim();
|
|
63
|
+
if (!prev || SEPARATOR.test(prev))
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// A tab strip like "☐ Colour ☐ Pet ✔ Submit" is navigation, not content.
|
|
69
|
+
if (/[☐☒✔✓].*\s{2,}[☐☒✔✓]/u.test(l))
|
|
70
|
+
continue;
|
|
71
|
+
block.unshift(l);
|
|
72
|
+
}
|
|
73
|
+
if (!block.length)
|
|
74
|
+
return { question: '', detail: [] };
|
|
75
|
+
let qi = -1;
|
|
76
|
+
for (let i = block.length - 1; i >= 0; i--)
|
|
77
|
+
if (/\?\s*$/.test(block[i])) {
|
|
78
|
+
qi = i;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
if (qi < 0)
|
|
82
|
+
qi = block.length - 1;
|
|
83
|
+
return { question: block[qi], detail: block.filter((_, i) => i !== qi) };
|
|
84
|
+
}
|
|
85
|
+
/** "❯ 1. Yes / 2. No" with indented hints under rows, scanning up from `bottom`. */
|
|
86
|
+
function numberedMenu(lines, bottom) {
|
|
87
|
+
const opts = [];
|
|
88
|
+
let hints = [];
|
|
89
|
+
let start = -1;
|
|
90
|
+
let labelCol = 4;
|
|
91
|
+
let separators = 0;
|
|
92
|
+
for (let i = bottom; i >= 0; i--) {
|
|
93
|
+
const raw = lines[i];
|
|
94
|
+
if (!raw.trim()) {
|
|
95
|
+
if (opts.length || hints.length)
|
|
96
|
+
break;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (SEPARATOR.test(raw.trim())) {
|
|
100
|
+
if (++separators > 2)
|
|
101
|
+
break;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const m = raw.match(NUMBERED);
|
|
105
|
+
if (m) {
|
|
106
|
+
const ck = checkState(m[4]);
|
|
107
|
+
opts.unshift({ n: Number(m[3]), label: ck.label, hint: hints.length ? hints.join(' ') : undefined, selected: !!m[2], checked: ck.checked });
|
|
108
|
+
hints = [];
|
|
109
|
+
labelCol = raw.indexOf(m[4]);
|
|
110
|
+
start = i;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// Deeper than an option's label: that option's explanation (it sits
|
|
114
|
+
// below its option, so it is seen first when scanning upwards).
|
|
115
|
+
if (raw.search(/\S/) >= Math.max(4, labelCol) && hints.length < 3) {
|
|
116
|
+
hints.unshift(raw.trim());
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
if (opts.length < 2)
|
|
122
|
+
return null;
|
|
123
|
+
// Must count 1, 2, 3… — a numbered list in prose is not a menu.
|
|
124
|
+
if (opts[0].n !== 1 || opts.some((o, i) => o.n !== i + 1))
|
|
125
|
+
return null;
|
|
126
|
+
return { opts, start };
|
|
127
|
+
}
|
|
128
|
+
/** Unnumbered rows with one cursor, only trusted when a key-hint footer is under them. */
|
|
129
|
+
function bareMenu(lines, bottom) {
|
|
130
|
+
const opts = [];
|
|
131
|
+
let start = -1;
|
|
132
|
+
let indent = -1;
|
|
133
|
+
for (let i = bottom; i >= 0 && opts.length < 9; i--) {
|
|
134
|
+
const raw = lines[i];
|
|
135
|
+
if (!raw.trim()) {
|
|
136
|
+
if (opts.length)
|
|
137
|
+
break;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const b = raw.match(BARE);
|
|
141
|
+
// The question above the rows ends in "?" or ":"; it is not a row.
|
|
142
|
+
if (!b || (!b[2] && /[?:]\s*$/.test(b[3])))
|
|
143
|
+
break;
|
|
144
|
+
const col = raw.indexOf(b[3]);
|
|
145
|
+
if (indent >= 0 && Math.abs(col - indent) > 1)
|
|
146
|
+
break;
|
|
147
|
+
indent = col;
|
|
148
|
+
const ck = checkState(b[3]);
|
|
149
|
+
opts.unshift({ n: 0, label: ck.label, selected: !!b[2], checked: ck.checked });
|
|
150
|
+
start = i;
|
|
151
|
+
}
|
|
152
|
+
if (opts.length < 2 || opts.filter((o) => o.selected).length !== 1)
|
|
153
|
+
return null;
|
|
154
|
+
opts.forEach((o, i) => { o.n = i + 1; });
|
|
155
|
+
return { opts, start };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The prompt on screen, or null. Looks only at the bottom of the pane (a
|
|
159
|
+
* prompt is always the last thing drawn) and demands a cursor-marked list:
|
|
160
|
+
* either numbered from 1 with the cursor on a row, or any list followed by a
|
|
161
|
+
* key-hint footer. A shell prompt ("❯ ") or a numbered list in the agent's
|
|
162
|
+
* reply has neither, which is what keeps false positives out.
|
|
163
|
+
*/
|
|
164
|
+
function detectPanePrompt(pane) {
|
|
165
|
+
const all = pane.replace(/\r/g, '').split('\n').map(clean);
|
|
166
|
+
while (all.length && !all[all.length - 1].trim())
|
|
167
|
+
all.pop();
|
|
168
|
+
const lines = all.slice(-45);
|
|
169
|
+
if (lines.length < 2)
|
|
170
|
+
return null;
|
|
171
|
+
const footerAt = (() => {
|
|
172
|
+
// Status lines can sit under a prompt (Claude Code's statusline is
|
|
173
|
+
// user-configured and may be several lines), so look a little way up.
|
|
174
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - TRAILING); i--)
|
|
175
|
+
if (FOOTER.test(lines[i]))
|
|
176
|
+
return i;
|
|
177
|
+
return -1;
|
|
178
|
+
})();
|
|
179
|
+
let menu = null;
|
|
180
|
+
if (footerAt >= 0)
|
|
181
|
+
menu = numberedMenu(lines, footerAt - 1) || bareMenu(lines, footerAt - 1);
|
|
182
|
+
else {
|
|
183
|
+
// No footer: the menu may still have status lines under it.
|
|
184
|
+
for (let b = lines.length - 1; b >= Math.max(0, lines.length - TRAILING) && !menu; b--) {
|
|
185
|
+
const m = numberedMenu(lines, b);
|
|
186
|
+
if (m && m.opts.some((o) => o.selected))
|
|
187
|
+
menu = m;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!menu)
|
|
191
|
+
return null;
|
|
192
|
+
const { opts, start } = menu;
|
|
193
|
+
if (!opts.some((o) => o.selected) && footerAt < 0)
|
|
194
|
+
return null;
|
|
195
|
+
const { question, detail } = above(lines, start);
|
|
196
|
+
const footer = footerAt >= 0 ? lines[footerAt].trim() : '';
|
|
197
|
+
const multi = opts.some((o) => o.checked !== null);
|
|
198
|
+
const text = [question, ...detail, ...opts.map((o) => `${o.n}${o.label}${o.checked}`), footer].join('\n');
|
|
199
|
+
return {
|
|
200
|
+
id: hash(text),
|
|
201
|
+
detail,
|
|
202
|
+
question: question || 'The agent is waiting for you',
|
|
203
|
+
options: opts,
|
|
204
|
+
multi,
|
|
205
|
+
footer,
|
|
206
|
+
approval: APPROVAL.test([question, ...detail].join(' ')) && !/type something/i.test(opts.map((o) => o.label).join(' ')),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/** Key names the agent may send to a pane, besides single characters. */
|
|
210
|
+
exports.PROMPT_KEYS = ['Up', 'Down', 'Left', 'Right', 'Enter', 'Escape', 'Tab', 'BTab', 'Space', 'BSpace', 'C-c'];
|
|
211
|
+
/** A key list is safe to hand to `tmux send-keys`: named keys or one printable character each. */
|
|
212
|
+
function isSafeKeyList(keys) {
|
|
213
|
+
return Array.isArray(keys) && keys.length > 0 && keys.length <= 40
|
|
214
|
+
&& keys.every((k) => typeof k === 'string' && (exports.PROMPT_KEYS.includes(k) || /^[A-Za-z0-9]$/.test(k)));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Keys that choose option `n` (1-based): move the cursor from where it is to
|
|
218
|
+
* that row with arrows, then Enter. Arrows rather than the digit shortcut
|
|
219
|
+
* because every menu honours arrows, and only some honour digits. On a
|
|
220
|
+
* multi-select row Enter toggles it; the menu stays up and is re-read.
|
|
221
|
+
*/
|
|
222
|
+
function promptKeys(p, n) {
|
|
223
|
+
const from = Math.max(0, p.options.findIndex((o) => o.selected));
|
|
224
|
+
const to = Math.max(0, Math.min(p.options.length - 1, n - 1));
|
|
225
|
+
const move = to > from ? Array(to - from).fill('Down') : Array(from - to).fill('Up');
|
|
226
|
+
return [...move, 'Enter'];
|
|
227
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AiKind } from './kinds';
|
|
2
2
|
import type { AiStatus } from './events';
|
|
3
3
|
import type { PermissionMode } from './permission';
|
|
4
|
+
import type { PanePrompt } from './pane-prompt';
|
|
4
5
|
export interface AiSessionSummary {
|
|
5
6
|
tmuxName: string;
|
|
6
7
|
kind: AiKind;
|
|
@@ -22,6 +23,12 @@ export interface AiSessionSummary {
|
|
|
22
23
|
*/
|
|
23
24
|
startedAt: number;
|
|
24
25
|
transcript: string;
|
|
26
|
+
/**
|
|
27
|
+
* A menu the agent's TUI is showing (permission, plan approval, question),
|
|
28
|
+
* read from the pane — the transcript never records most of these. Absent
|
|
29
|
+
* from agents before 1.9.
|
|
30
|
+
*/
|
|
31
|
+
prompt?: PanePrompt | null;
|
|
25
32
|
}
|
|
26
33
|
export declare const REPLAY_LIMIT = 200;
|
|
27
34
|
//# sourceMappingURL=session.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACtC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACtC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,QAAQ,CAAC;IAEjB,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;IAC5B,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;IAGlB,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC;CAC5B;AAID,eAAO,MAAM,YAAY,MAAM,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"entries": {
|
|
4
|
+
"79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": {
|
|
5
|
+
"last_emitted_at_ms": 1792572432915,
|
|
6
|
+
"message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)."
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"updated_at": "2026-10-21T08:47:12.915Z"
|
|
10
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PanePrompt } from './pane-prompt';
|
|
1
2
|
import type { AiEvent } from './events';
|
|
2
3
|
|
|
3
4
|
// How a client folds ai_session_events messages into its transcript.
|
|
@@ -37,6 +38,12 @@ export interface EventsMeta {
|
|
|
37
38
|
sessionKey?: string;
|
|
38
39
|
/** Who/where, sent with a replay so a captured log can be labelled. */
|
|
39
40
|
session?: { kind: string; folder: string; gitBranch: string; title: string };
|
|
41
|
+
/**
|
|
42
|
+
* What the agent's TUI is asking right now, read from its pane (see
|
|
43
|
+
* pane-prompt.ts). null = nothing asked any more; absent = not reported
|
|
44
|
+
* (agents before 1.9, or a message that is only about events).
|
|
45
|
+
*/
|
|
46
|
+
prompt?: PanePrompt | null;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
export const EMPTY_TRANSCRIPT: TranscriptState = { events: [], firstSeq: null, earlier: 0 };
|
|
@@ -12,7 +12,7 @@ export { REPLAY_LIMIT } from './session';
|
|
|
12
12
|
|
|
13
13
|
export type { CommandSource, AiCommand, McpServerInfo, AiCapabilities } from './capabilities';
|
|
14
14
|
|
|
15
|
-
export { NOTIFY_COOLDOWN_MS, shouldNotifyAi } from './notify';
|
|
15
|
+
export { NOTIFY_COOLDOWN_MS, shouldNotifyAi, shouldNotifyPrompt } from './notify';
|
|
16
16
|
|
|
17
17
|
export type { ToolItem, ChatBlock } from './chat-blocks';
|
|
18
18
|
export { groupEvents, toolGroupStartsOpen } from './chat-blocks';
|
|
@@ -40,3 +40,6 @@ export {
|
|
|
40
40
|
|
|
41
41
|
export type { TranscriptState, EventsMeta } from './event-merge';
|
|
42
42
|
export { EMPTY_TRANSCRIPT, mergeEvents } from './event-merge';
|
|
43
|
+
|
|
44
|
+
export type { PanePrompt, PanePromptOption, PromptKey } from './pane-prompt';
|
|
45
|
+
export { PROMPT_KEYS, detectPanePrompt, isSafeKeyList, promptKeys } from './pane-prompt';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { shouldNotifyAi } from './notify';
|
|
2
|
+
import { NOTIFY_COOLDOWN_MS, shouldNotifyAi, shouldNotifyPrompt } from './notify';
|
|
3
3
|
|
|
4
4
|
// This is the logic most capable of annoying someone into turning
|
|
5
5
|
// notifications off, so every rule gets a case.
|
|
@@ -47,3 +47,14 @@ describe('shouldNotifyAi', () => {
|
|
|
47
47
|
expect(shouldNotifyAi('working', 'waiting_input', NOW - 200_000, NOW)).toBe(true);
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
describe('shouldNotifyPrompt', () => {
|
|
52
|
+
it('fires for a new question, not on first sight, not twice, not inside the cooldown', () => {
|
|
53
|
+
expect(shouldNotifyPrompt(undefined, 'a', undefined, 1000)).toBe(false);
|
|
54
|
+
expect(shouldNotifyPrompt(null, 'a', undefined, 1000)).toBe(true);
|
|
55
|
+
expect(shouldNotifyPrompt('a', 'a', undefined, 1000)).toBe(false);
|
|
56
|
+
expect(shouldNotifyPrompt('a', null, undefined, 1000)).toBe(false);
|
|
57
|
+
expect(shouldNotifyPrompt('a', 'b', 1000, 1000 + NOTIFY_COOLDOWN_MS - 1)).toBe(false);
|
|
58
|
+
expect(shouldNotifyPrompt('a', 'b', 1000, 1000 + NOTIFY_COOLDOWN_MS)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -28,3 +28,21 @@ export function shouldNotifyAi(
|
|
|
28
28
|
if (lastNotifiedAt && nowMs - lastNotifiedAt < NOTIFY_COOLDOWN_MS) return false;
|
|
29
29
|
return true;
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A new question on an agent's screen (a pane prompt id not seen before) is
|
|
34
|
+
* always news, whatever the status said: a session nobody has open reports
|
|
35
|
+
* 'idle' until asked, so the working → waiting rule above never sees it.
|
|
36
|
+
* Still not on first sight (reconnect bursts) and still rate-limited.
|
|
37
|
+
*/
|
|
38
|
+
export function shouldNotifyPrompt(
|
|
39
|
+
previousPromptId: string | null | undefined,
|
|
40
|
+
nextPromptId: string | null | undefined,
|
|
41
|
+
lastNotifiedAt: number | undefined,
|
|
42
|
+
nowMs: number,
|
|
43
|
+
): boolean {
|
|
44
|
+
if (previousPromptId === undefined) return false;
|
|
45
|
+
if (!nextPromptId || nextPromptId === previousPromptId) return false;
|
|
46
|
+
if (lastNotifiedAt && nowMs - lastNotifiedAt < NOTIFY_COOLDOWN_MS) return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { detectPanePrompt, isSafeKeyList, promptKeys } from './pane-prompt';
|
|
3
|
+
|
|
4
|
+
// Shapes as the CLIs draw them (spacing kept), plus the noise around them.
|
|
5
|
+
const CLAUDE_BASH = `
|
|
6
|
+
⏺ I'll run the tests.
|
|
7
|
+
|
|
8
|
+
╭──────────────────────────────────────────────────────────────╮
|
|
9
|
+
│ Bash command │
|
|
10
|
+
│ │
|
|
11
|
+
│ npm test │
|
|
12
|
+
│ Run the test suite │
|
|
13
|
+
│ │
|
|
14
|
+
│ Do you want to proceed? │
|
|
15
|
+
│ ❯ 1. Yes │
|
|
16
|
+
│ 2. Yes, and don't ask again for npm test commands in /app │
|
|
17
|
+
│ 3. No, and tell Claude what to do differently (esc) │
|
|
18
|
+
╰──────────────────────────────────────────────────────────────╯
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
const CLAUDE_QUESTION = `
|
|
22
|
+
⏺ Let me ask a couple of things first.
|
|
23
|
+
|
|
24
|
+
────────────────────────────────────────────────────────────────
|
|
25
|
+
☐ Colour ☐ Pet ✔ Submit →
|
|
26
|
+
|
|
27
|
+
Which colour do you prefer?
|
|
28
|
+
|
|
29
|
+
❯ 1. Red
|
|
30
|
+
Warm and loud
|
|
31
|
+
2. Green
|
|
32
|
+
Calm
|
|
33
|
+
3. Type something.
|
|
34
|
+
────────────────────────────────────────────────────────────────
|
|
35
|
+
4. Chat about this
|
|
36
|
+
|
|
37
|
+
Enter to select · Tab/Arrow keys to navigate · Esc to cancel
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const CLAUDE_TRUST = `
|
|
41
|
+
Accessing workspace:
|
|
42
|
+
|
|
43
|
+
/Users/me/code/app
|
|
44
|
+
|
|
45
|
+
Quick safety check: Is this a project you created or one you trust?
|
|
46
|
+
|
|
47
|
+
❯ No, exit
|
|
48
|
+
Yes, I trust this folder
|
|
49
|
+
|
|
50
|
+
Enter to confirm · Esc to cancel
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
const CODEX_APPROVAL = `
|
|
54
|
+
• Running tests
|
|
55
|
+
|
|
56
|
+
Would you like to run the following command?
|
|
57
|
+
|
|
58
|
+
$ npm test
|
|
59
|
+
|
|
60
|
+
› 1. Yes, proceed (y)
|
|
61
|
+
2. Yes, and don't ask again for this command (a)
|
|
62
|
+
3. No, and tell Codex what to do differently (esc)
|
|
63
|
+
|
|
64
|
+
Press enter to confirm or esc to cancel
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
const MULTI = `
|
|
68
|
+
Which features should I add?
|
|
69
|
+
|
|
70
|
+
❯ 1. [ ] Dark mode
|
|
71
|
+
2. [✔] Search
|
|
72
|
+
3. [ ] Export
|
|
73
|
+
4. Submit
|
|
74
|
+
|
|
75
|
+
Enter to select · ↑/↓ to navigate · Esc to cancel
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
const IDLE_CLAUDE = `
|
|
79
|
+
⏺ Done. Here is what changed:
|
|
80
|
+
|
|
81
|
+
1. Fixed the parser
|
|
82
|
+
2. Added tests
|
|
83
|
+
3. Updated docs
|
|
84
|
+
|
|
85
|
+
────────────────────────────────────────────────────────────────
|
|
86
|
+
❯
|
|
87
|
+
────────────────────────────────────────────────────────────────
|
|
88
|
+
⏵⏵ auto mode on (shift+tab to cycle)
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
const SHELL = `
|
|
92
|
+
~/code/app main
|
|
93
|
+
❯ ls
|
|
94
|
+
README.md package.json src
|
|
95
|
+
~/code/app main
|
|
96
|
+
❯
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
describe('detectPanePrompt', () => {
|
|
100
|
+
it('reads a Claude permission prompt inside a box', () => {
|
|
101
|
+
const p = detectPanePrompt(CLAUDE_BASH)!;
|
|
102
|
+
expect(p.question).toBe('Do you want to proceed?');
|
|
103
|
+
expect(p.detail).toEqual(['Bash command', 'npm test', 'Run the test suite']);
|
|
104
|
+
expect(p.options.map((o) => o.label)).toEqual([
|
|
105
|
+
'Yes', "Yes, and don't ask again for npm test commands in /app", 'No, and tell Claude what to do differently (esc)',
|
|
106
|
+
]);
|
|
107
|
+
expect(p.options[0].selected).toBe(true);
|
|
108
|
+
expect(p.approval).toBe(true);
|
|
109
|
+
expect(p.multi).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('reads a question menu with hints, a separator and a footer', () => {
|
|
113
|
+
const p = detectPanePrompt(CLAUDE_QUESTION)!;
|
|
114
|
+
expect(p.question).toBe('Which colour do you prefer?');
|
|
115
|
+
expect(p.options.map((o) => o.label)).toEqual(['Red', 'Green', 'Type something.', 'Chat about this']);
|
|
116
|
+
expect(p.options[0].hint).toBe('Warm and loud');
|
|
117
|
+
expect(p.options[1].hint).toBe('Calm');
|
|
118
|
+
expect(p.footer).toMatch(/Enter to select/);
|
|
119
|
+
expect(p.approval).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('reads an unnumbered menu when a footer vouches for it', () => {
|
|
123
|
+
const p = detectPanePrompt(CLAUDE_TRUST)!;
|
|
124
|
+
expect(p.options.map((o) => [o.n, o.label, o.selected])).toEqual([[1, 'No, exit', true], [2, 'Yes, I trust this folder', false]]);
|
|
125
|
+
expect(p.question).toMatch(/one you trust\?$/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('reads a Codex approval', () => {
|
|
129
|
+
const p = detectPanePrompt(CODEX_APPROVAL)!;
|
|
130
|
+
expect(p.question).toBe('Would you like to run the following command?');
|
|
131
|
+
expect(p.detail).toContain('$ npm test');
|
|
132
|
+
expect(p.options).toHaveLength(3);
|
|
133
|
+
expect(p.options[0].selected).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('marks multi-select rows', () => {
|
|
137
|
+
const p = detectPanePrompt(MULTI)!;
|
|
138
|
+
expect(p.multi).toBe(true);
|
|
139
|
+
expect(p.options.map((o) => o.checked)).toEqual([false, true, false, null]);
|
|
140
|
+
expect(p.options[1].label).toBe('Search');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('finds a prompt under several status lines', () => {
|
|
144
|
+
const status = '\n [Opus 5.5] │ app │ main\n Context ░░░░ 12%\n Usage ok\n ⏵⏵ auto mode on (shift+tab to cycle)\n';
|
|
145
|
+
const noFooter = CLAUDE_BASH.trimEnd() + status;
|
|
146
|
+
expect(detectPanePrompt(noFooter)?.question).toBe('Do you want to proceed?');
|
|
147
|
+
expect(detectPanePrompt(CLAUDE_TRUST.trimEnd() + status)?.options).toHaveLength(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('ignores an idle agent, a numbered reply and a shell prompt', () => {
|
|
151
|
+
expect(detectPanePrompt(IDLE_CLAUDE)).toBeNull();
|
|
152
|
+
expect(detectPanePrompt(SHELL)).toBeNull();
|
|
153
|
+
expect(detectPanePrompt('')).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('keeps its id while the cursor moves, changes it for a new question', () => {
|
|
157
|
+
const a = detectPanePrompt(CLAUDE_BASH)!;
|
|
158
|
+
expect(detectPanePrompt(CLAUDE_BASH.replace('❯ 1. Yes', ' 1. Yes').replace('│ 2. Yes,', '│ ❯ 2. Yes,'))!.id).toBe(a.id);
|
|
159
|
+
expect(detectPanePrompt(CLAUDE_BASH.replace('npm test ', 'npm run x'))!.id).not.toBe(a.id);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('promptKeys', () => {
|
|
164
|
+
it('moves from the cursor to the target, then Enter', () => {
|
|
165
|
+
const p = detectPanePrompt(CLAUDE_BASH)!;
|
|
166
|
+
expect(promptKeys(p, 1)).toEqual(['Enter']);
|
|
167
|
+
expect(promptKeys(p, 3)).toEqual(['Down', 'Down', 'Enter']);
|
|
168
|
+
const moved = detectPanePrompt(CLAUDE_BASH.replace('❯ 1. Yes', ' 1. Yes').replace('│ 2. Yes,', '│ ❯ 2. Yes,'))!;
|
|
169
|
+
expect(promptKeys(moved, 1)).toEqual(['Up', 'Enter']);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('only lets named keys and single characters through', () => {
|
|
173
|
+
expect(isSafeKeyList(['Down', 'Enter', 'y', '1'])).toBe(true);
|
|
174
|
+
expect(isSafeKeyList(['rm -rf /'])).toBe(false);
|
|
175
|
+
expect(isSafeKeyList([';', 'Enter'])).toBe(false);
|
|
176
|
+
expect(isSafeKeyList([])).toBe(false);
|
|
177
|
+
expect(isSafeKeyList('Enter')).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|