@indianic/dialout 1.8.1 → 1.10.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-attachments.d.ts +18 -0
- package/dist/ai-attachments.d.ts.map +1 -0
- package/dist/ai-attachments.js +110 -0
- package/dist/ai-attachments.js.map +1 -0
- 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.d.ts.map +1 -1
- package/dist/websocket.js +20 -0
- package/dist/websocket.js.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/attachments.d.ts +33 -0
- package/node_modules/@indianic/dialout-shared/dist/attachments.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/attachments.js +84 -0
- 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 +5 -1
- package/node_modules/@indianic/dialout-shared/dist/index.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/index.js +15 -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/attachments.test.ts +33 -0
- package/node_modules/@indianic/dialout-shared/src/attachments.ts +82 -0
- package/node_modules/@indianic/dialout-shared/src/event-merge.ts +7 -0
- package/node_modules/@indianic/dialout-shared/src/index.ts +10 -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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { composeWithAttachments, safeAttachmentName, splitAttachments } from './attachments';
|
|
3
|
+
|
|
4
|
+
const P1 = '/Users/me/.dialout/attachments/2026-10-21/a1b2c3d4-shot.png';
|
|
5
|
+
const P2 = '/Users/me/.dialout/attachments/2026-10-21/0f0f0f0f-spec.pdf';
|
|
6
|
+
|
|
7
|
+
describe('attachments', () => {
|
|
8
|
+
it('makes names safe for disk and for a CLI line', () => {
|
|
9
|
+
expect(safeAttachmentName('Screen Shot 2026-10-21 at 3.04 PM.PNG')).toBe('Screen-Shot-2026-10-21-at-3.04-PM.png');
|
|
10
|
+
expect(safeAttachmentName('../../etc/passwd')).toBe('passwd');
|
|
11
|
+
expect(safeAttachmentName('.env')).toBe('env');
|
|
12
|
+
expect(safeAttachmentName('')).toBe('file');
|
|
13
|
+
expect(safeAttachmentName('a'.repeat(200) + '.jpeg').length).toBeLessThanOrEqual(80);
|
|
14
|
+
expect(safeAttachmentName('résumé (final).pdf')).toBe('resume-final.pdf');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('references files the way each CLI reads them, on one line', () => {
|
|
18
|
+
expect(composeWithAttachments('claude', 'Fix this\nlayout', [P1])).toBe(`Fix this layout @${P1}`);
|
|
19
|
+
expect(composeWithAttachments('codex', 'Look', [P1, P2])).toBe(`Look (attached: ${P1}, ${P2})`);
|
|
20
|
+
expect(composeWithAttachments('claude', '', [P1])).toBe(`@${P1}`);
|
|
21
|
+
expect(composeWithAttachments('grok', 'hi', [])).toBe('hi');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('turns references back into chips, leaving other paths alone', () => {
|
|
25
|
+
const a = splitAttachments(composeWithAttachments('claude', 'Fix /src/app.ts please', [P1, P2]));
|
|
26
|
+
expect(a.text).toBe('Fix /src/app.ts please');
|
|
27
|
+
expect(a.files.map((f) => [f.name, f.image])).toEqual([['shot.png', true], ['spec.pdf', false]]);
|
|
28
|
+
const b = splitAttachments(composeWithAttachments('codex', 'Look', [P1]));
|
|
29
|
+
expect(b.text).toBe('Look');
|
|
30
|
+
expect(b.files[0].path).toBe(P1);
|
|
31
|
+
expect(splitAttachments('nothing here (attached: soon)').text).toBe('nothing here (attached: soon)');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Files and images attached to an AI chat message.
|
|
2
|
+
//
|
|
3
|
+
// A coding CLI cannot receive a file over its TUI, but every one of them can
|
|
4
|
+
// read a file by path: Claude Code natively (`@/path` mentions, images
|
|
5
|
+
// included), Codex and Grok through their read/view tools. So an attachment
|
|
6
|
+
// is uploaded to the machine the agent runs on, saved under
|
|
7
|
+
// ~/.dialout/attachments, and the message carries its path. The same rules
|
|
8
|
+
// are used by the web app, the phone app and the agent.
|
|
9
|
+
|
|
10
|
+
/** Per file. Images from a phone camera fit comfortably; videos do not belong here. */
|
|
11
|
+
export const ATTACHMENT_MAX_BYTES = 15 * 1024 * 1024;
|
|
12
|
+
export const ATTACHMENT_MAX_FILES = 8;
|
|
13
|
+
/** Where the agent keeps them, relative to the user's home. */
|
|
14
|
+
export const ATTACHMENT_DIR = '.dialout/attachments';
|
|
15
|
+
|
|
16
|
+
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|heic|heif|bmp|svg)$/i;
|
|
17
|
+
|
|
18
|
+
export function isImageName(name: string): boolean {
|
|
19
|
+
return IMAGE_EXT.test(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A name safe to put on disk and inside a message: letters, digits, dot,
|
|
24
|
+
* dash and underscore only, no leading dots, at most 80 characters with the
|
|
25
|
+
* extension kept. No spaces, so a path never has to be quoted for a CLI.
|
|
26
|
+
*/
|
|
27
|
+
export function safeAttachmentName(name: string): string {
|
|
28
|
+
const base = String(name || '').split(/[\\/]/).pop() || '';
|
|
29
|
+
const dot = base.lastIndexOf('.');
|
|
30
|
+
const ext = dot > 0 ? base.slice(dot).toLowerCase().replace(/[^a-z0-9.]/g, '').slice(0, 10) : '';
|
|
31
|
+
const stem = (dot > 0 ? base.slice(0, dot) : base)
|
|
32
|
+
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^\w.-]+/g, '-').replace(/^[.-]+|[.-]+$/g, '').replace(/-{2,}/g, '-')
|
|
33
|
+
.slice(0, 80 - ext.length);
|
|
34
|
+
return `${stem || 'file'}${ext}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The message with its attachments referenced the way each CLI reads them.
|
|
39
|
+
* One line on purpose: a newline typed into a TUI can submit the message
|
|
40
|
+
* half-written.
|
|
41
|
+
*/
|
|
42
|
+
export function composeWithAttachments(kind: string, text: string, paths: string[]): string {
|
|
43
|
+
const body = text.replace(/\s*\n+\s*/g, ' ').trim();
|
|
44
|
+
if (!paths.length) return body;
|
|
45
|
+
const refs = kind === 'claude'
|
|
46
|
+
? paths.map((p) => `@${p}`).join(' ')
|
|
47
|
+
: `(attached: ${paths.join(', ')})`;
|
|
48
|
+
return body ? `${body} ${refs}` : refs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type AttachmentRef = { path: string; name: string; image: boolean };
|
|
52
|
+
|
|
53
|
+
const IN_DIR = /^(?:~|\/[^\s,()]*?)\/\.dialout\/attachments\/[^\s,()]+$/;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Pull attachment paths back out of a sent message, so a chat bubble can show
|
|
57
|
+
* them as chips instead of long paths. Only paths inside the attachments
|
|
58
|
+
* folder count; any other path the user typed stays text.
|
|
59
|
+
*/
|
|
60
|
+
export function splitAttachments(message: string): { text: string; files: AttachmentRef[] } {
|
|
61
|
+
const files: AttachmentRef[] = [];
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
const text = message
|
|
64
|
+
.replace(/\(attached:\s*([^)]*)\)/g, (whole, list: string) => {
|
|
65
|
+
const paths = list.split(',').map((s) => s.trim()).filter((s) => IN_DIR.test(s));
|
|
66
|
+
if (!paths.length) return whole;
|
|
67
|
+
for (const p of paths) add(p);
|
|
68
|
+
return '';
|
|
69
|
+
})
|
|
70
|
+
.replace(/@?((?:~|\/[^\s,()]*?)\/\.dialout\/attachments\/[^\s,()]+)/g, (_w, p: string) => { add(p); return ''; })
|
|
71
|
+
.replace(/\s{2,}/g, ' ')
|
|
72
|
+
.trim();
|
|
73
|
+
function add(p: string) {
|
|
74
|
+
if (seen.has(p)) return;
|
|
75
|
+
seen.add(p);
|
|
76
|
+
const file = p.split('/').pop() || p;
|
|
77
|
+
// Saved names carry an 8-character id prefix ("a1b2c3d4-shot.png").
|
|
78
|
+
const name = file.replace(/^[0-9a-f]{8}-/, '');
|
|
79
|
+
files.push({ path: p, name, image: isImageName(name) });
|
|
80
|
+
}
|
|
81
|
+
return { text, files };
|
|
82
|
+
}
|
|
@@ -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,12 @@ 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';
|
|
46
|
+
|
|
47
|
+
export type { AttachmentRef } from './attachments';
|
|
48
|
+
export {
|
|
49
|
+
ATTACHMENT_DIR, ATTACHMENT_MAX_BYTES, ATTACHMENT_MAX_FILES,
|
|
50
|
+
composeWithAttachments, isImageName, safeAttachmentName, splitAttachments,
|
|
51
|
+
} from './attachments';
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// A coding agent waiting on the user, read from what its TUI is showing.
|
|
2
|
+
//
|
|
3
|
+
// Permission prompts, plan approvals, trust prompts and question menus are
|
|
4
|
+
// drawn in the terminal and most never reach the transcript, so a chat built
|
|
5
|
+
// from the transcript alone looks stuck while the agent is asking something.
|
|
6
|
+
// The agent captures the pane and runs it through detectPanePrompt; clients
|
|
7
|
+
// render the result as tappable choices and answer with promptKeys.
|
|
8
|
+
//
|
|
9
|
+
// One parser for every CLI on purpose: Claude Code, Codex and Grok all draw
|
|
10
|
+
// the same shape — a question, then a list with a cursor on one row
|
|
11
|
+
// (❯ / › / >), usually numbered, then a footer like "Enter to confirm · Esc to
|
|
12
|
+
// cancel". Recognising the shape rather than each vendor's wording is what
|
|
13
|
+
// keeps this working across their releases.
|
|
14
|
+
|
|
15
|
+
export interface PanePromptOption {
|
|
16
|
+
/** 1-based position in the menu. */
|
|
17
|
+
n: number;
|
|
18
|
+
label: string;
|
|
19
|
+
/** Indented line(s) under the option: its explanation. */
|
|
20
|
+
hint?: string;
|
|
21
|
+
/** The row the cursor is on. */
|
|
22
|
+
selected: boolean;
|
|
23
|
+
/** Multi-select rows: ticked or not. null for an ordinary row. */
|
|
24
|
+
checked: boolean | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PanePrompt {
|
|
28
|
+
/** Changes when the question or its choices change (not when the cursor moves). */
|
|
29
|
+
id: string;
|
|
30
|
+
/** Context above the question: "Bash command", the command itself, a file name. */
|
|
31
|
+
detail: string[];
|
|
32
|
+
question: string;
|
|
33
|
+
options: PanePromptOption[];
|
|
34
|
+
/** Some rows are checkboxes: a tap toggles rather than answers. */
|
|
35
|
+
multi: boolean;
|
|
36
|
+
/** The menu's own key hint, shown small under the choices. */
|
|
37
|
+
footer: string;
|
|
38
|
+
/** Approval-shaped (run/edit/trust) rather than a question to answer. */
|
|
39
|
+
approval: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Box-drawing borders some CLIs draw around a prompt: stripped before parsing,
|
|
43
|
+
// keeping the indentation inside the box (it tells options from their hints).
|
|
44
|
+
const BORDER_LEFT = /^\s*[│┃║] ?/u;
|
|
45
|
+
const BORDER_RIGHT = /\s*[│┃║]\s*$/u;
|
|
46
|
+
/** Lines under a prompt that may still belong to the TUI (status lines). */
|
|
47
|
+
const TRAILING = 14;
|
|
48
|
+
const SEPARATOR = /^[\s─━═╌┄┈\-_╭╮╰╯┌┐└┘╔╗╚╝]{4,}$/u;
|
|
49
|
+
const FOOTER = /(enter to (confirm|select|submit)|esc to (cancel|exit|interrupt)|to navigate|press enter|↑\/↓|tab\/arrow|y\/n)/i;
|
|
50
|
+
const CHECK_ON = /^\[(x|✔|✓|■|●)\]|^[◉☑✔✓■]/iu;
|
|
51
|
+
const CHECK_OFF = /^\[ \]|^[◯○☐□]/u;
|
|
52
|
+
const APPROVAL = /\b(proceed|allow|approve|permission|trust|run|execute|edit|create|delete|make this edit|overwrite|apply)\b/i;
|
|
53
|
+
|
|
54
|
+
// "❯ 1. Yes" " 2. No (esc)" "› 1. Yes, proceed (y)"
|
|
55
|
+
const NUMBERED = /^(\s*)([❯›>▶→➜●])?\s*(\d{1,2})[.)]\s+(.+?)\s*$/u;
|
|
56
|
+
// "❯ Yes, I trust this folder" / " No, exit" — unnumbered rows
|
|
57
|
+
const BARE = /^(\s*)(?:([❯›>▶→➜●])\s+)?(\S.*?)\s*$/u;
|
|
58
|
+
|
|
59
|
+
function clean(line: string): string {
|
|
60
|
+
return line.replace(BORDER_LEFT, '').replace(BORDER_RIGHT, '').replace(/\s+$/, '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hash(s: string): string {
|
|
64
|
+
let h = 0x811c9dc5;
|
|
65
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
|
|
66
|
+
return (h >>> 0).toString(36);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function checkState(label: string): { label: string; checked: boolean | null } {
|
|
70
|
+
if (CHECK_ON.test(label)) return { label: label.replace(CHECK_ON, '').trim(), checked: true };
|
|
71
|
+
if (CHECK_OFF.test(label)) return { label: label.replace(CHECK_OFF, '').trim(), checked: false };
|
|
72
|
+
return { label, checked: null };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Pull the question and its context from the lines above the menu. */
|
|
76
|
+
function above(lines: string[], end: number): { question: string; detail: string[] } {
|
|
77
|
+
const block: string[] = [];
|
|
78
|
+
for (let i = end - 1; i >= 0 && block.length < 8; i--) {
|
|
79
|
+
const l = lines[i].trim();
|
|
80
|
+
if (SEPARATOR.test(l)) break;
|
|
81
|
+
if (!l) { if (block.length) { const prev = lines[i - 1]?.trim(); if (!prev || SEPARATOR.test(prev)) break; } continue; }
|
|
82
|
+
// A tab strip like "☐ Colour ☐ Pet ✔ Submit" is navigation, not content.
|
|
83
|
+
if (/[☐☒✔✓].*\s{2,}[☐☒✔✓]/u.test(l)) continue;
|
|
84
|
+
block.unshift(l);
|
|
85
|
+
}
|
|
86
|
+
if (!block.length) return { question: '', detail: [] };
|
|
87
|
+
let qi = -1;
|
|
88
|
+
for (let i = block.length - 1; i >= 0; i--) if (/\?\s*$/.test(block[i])) { qi = i; break; }
|
|
89
|
+
if (qi < 0) qi = block.length - 1;
|
|
90
|
+
return { question: block[qi], detail: block.filter((_, i) => i !== qi) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type Menu = { opts: PanePromptOption[]; start: number };
|
|
94
|
+
|
|
95
|
+
/** "❯ 1. Yes / 2. No" with indented hints under rows, scanning up from `bottom`. */
|
|
96
|
+
function numberedMenu(lines: string[], bottom: number): Menu | null {
|
|
97
|
+
const opts: PanePromptOption[] = [];
|
|
98
|
+
let hints: string[] = [];
|
|
99
|
+
let start = -1;
|
|
100
|
+
let labelCol = 4;
|
|
101
|
+
let separators = 0;
|
|
102
|
+
for (let i = bottom; i >= 0; i--) {
|
|
103
|
+
const raw = lines[i];
|
|
104
|
+
if (!raw.trim()) { if (opts.length || hints.length) break; continue; }
|
|
105
|
+
if (SEPARATOR.test(raw.trim())) { if (++separators > 2) break; continue; }
|
|
106
|
+
const m = raw.match(NUMBERED);
|
|
107
|
+
if (m) {
|
|
108
|
+
const ck = checkState(m[4]);
|
|
109
|
+
opts.unshift({ n: Number(m[3]), label: ck.label, hint: hints.length ? hints.join(' ') : undefined, selected: !!m[2], checked: ck.checked });
|
|
110
|
+
hints = [];
|
|
111
|
+
labelCol = raw.indexOf(m[4]);
|
|
112
|
+
start = i;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// Deeper than an option's label: that option's explanation (it sits
|
|
116
|
+
// below its option, so it is seen first when scanning upwards).
|
|
117
|
+
if (raw.search(/\S/) >= Math.max(4, labelCol) && hints.length < 3) { hints.unshift(raw.trim()); continue; }
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
if (opts.length < 2) return null;
|
|
121
|
+
// Must count 1, 2, 3… — a numbered list in prose is not a menu.
|
|
122
|
+
if (opts[0].n !== 1 || opts.some((o, i) => o.n !== i + 1)) return null;
|
|
123
|
+
return { opts, start };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Unnumbered rows with one cursor, only trusted when a key-hint footer is under them. */
|
|
127
|
+
function bareMenu(lines: string[], bottom: number): Menu | null {
|
|
128
|
+
const opts: PanePromptOption[] = [];
|
|
129
|
+
let start = -1;
|
|
130
|
+
let indent = -1;
|
|
131
|
+
for (let i = bottom; i >= 0 && opts.length < 9; i--) {
|
|
132
|
+
const raw = lines[i];
|
|
133
|
+
if (!raw.trim()) { if (opts.length) break; continue; }
|
|
134
|
+
const b = raw.match(BARE);
|
|
135
|
+
// The question above the rows ends in "?" or ":"; it is not a row.
|
|
136
|
+
if (!b || (!b[2] && /[?:]\s*$/.test(b[3]))) break;
|
|
137
|
+
const col = raw.indexOf(b[3]);
|
|
138
|
+
if (indent >= 0 && Math.abs(col - indent) > 1) break;
|
|
139
|
+
indent = col;
|
|
140
|
+
const ck = checkState(b[3]);
|
|
141
|
+
opts.unshift({ n: 0, label: ck.label, selected: !!b[2], checked: ck.checked });
|
|
142
|
+
start = i;
|
|
143
|
+
}
|
|
144
|
+
if (opts.length < 2 || opts.filter((o) => o.selected).length !== 1) return null;
|
|
145
|
+
opts.forEach((o, i) => { o.n = i + 1; });
|
|
146
|
+
return { opts, start };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The prompt on screen, or null. Looks only at the bottom of the pane (a
|
|
151
|
+
* prompt is always the last thing drawn) and demands a cursor-marked list:
|
|
152
|
+
* either numbered from 1 with the cursor on a row, or any list followed by a
|
|
153
|
+
* key-hint footer. A shell prompt ("❯ ") or a numbered list in the agent's
|
|
154
|
+
* reply has neither, which is what keeps false positives out.
|
|
155
|
+
*/
|
|
156
|
+
export function detectPanePrompt(pane: string): PanePrompt | null {
|
|
157
|
+
const all = pane.replace(/\r/g, '').split('\n').map(clean);
|
|
158
|
+
while (all.length && !all[all.length - 1].trim()) all.pop();
|
|
159
|
+
const lines = all.slice(-45);
|
|
160
|
+
if (lines.length < 2) return null;
|
|
161
|
+
|
|
162
|
+
const footerAt = (() => {
|
|
163
|
+
// Status lines can sit under a prompt (Claude Code's statusline is
|
|
164
|
+
// user-configured and may be several lines), so look a little way up.
|
|
165
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - TRAILING); i--) if (FOOTER.test(lines[i])) return i;
|
|
166
|
+
return -1;
|
|
167
|
+
})();
|
|
168
|
+
|
|
169
|
+
let menu: Menu | null = null;
|
|
170
|
+
if (footerAt >= 0) menu = numberedMenu(lines, footerAt - 1) || bareMenu(lines, footerAt - 1);
|
|
171
|
+
else {
|
|
172
|
+
// No footer: the menu may still have status lines under it.
|
|
173
|
+
for (let b = lines.length - 1; b >= Math.max(0, lines.length - TRAILING) && !menu; b--) {
|
|
174
|
+
const m = numberedMenu(lines, b);
|
|
175
|
+
if (m && m.opts.some((o) => o.selected)) menu = m;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!menu) return null;
|
|
179
|
+
const { opts, start } = menu;
|
|
180
|
+
if (!opts.some((o) => o.selected) && footerAt < 0) return null;
|
|
181
|
+
|
|
182
|
+
const { question, detail } = above(lines, start);
|
|
183
|
+
const footer = footerAt >= 0 ? lines[footerAt].trim() : '';
|
|
184
|
+
const multi = opts.some((o) => o.checked !== null);
|
|
185
|
+
const text = [question, ...detail, ...opts.map((o) => `${o.n}${o.label}${o.checked}`), footer].join('\n');
|
|
186
|
+
return {
|
|
187
|
+
id: hash(text),
|
|
188
|
+
detail,
|
|
189
|
+
question: question || 'The agent is waiting for you',
|
|
190
|
+
options: opts,
|
|
191
|
+
multi,
|
|
192
|
+
footer,
|
|
193
|
+
approval: APPROVAL.test([question, ...detail].join(' ')) && !/type something/i.test(opts.map((o) => o.label).join(' ')),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Key names the agent may send to a pane, besides single characters. */
|
|
198
|
+
export const PROMPT_KEYS = ['Up', 'Down', 'Left', 'Right', 'Enter', 'Escape', 'Tab', 'BTab', 'Space', 'BSpace', 'C-c'] as const;
|
|
199
|
+
export type PromptKey = (typeof PROMPT_KEYS)[number];
|
|
200
|
+
|
|
201
|
+
/** A key list is safe to hand to `tmux send-keys`: named keys or one printable character each. */
|
|
202
|
+
export function isSafeKeyList(keys: unknown): keys is string[] {
|
|
203
|
+
return Array.isArray(keys) && keys.length > 0 && keys.length <= 40
|
|
204
|
+
&& keys.every((k) => typeof k === 'string' && ((PROMPT_KEYS as readonly string[]).includes(k) || /^[A-Za-z0-9]$/.test(k)));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Keys that choose option `n` (1-based): move the cursor from where it is to
|
|
209
|
+
* that row with arrows, then Enter. Arrows rather than the digit shortcut
|
|
210
|
+
* because every menu honours arrows, and only some honour digits. On a
|
|
211
|
+
* multi-select row Enter toggles it; the menu stays up and is re-read.
|
|
212
|
+
*/
|
|
213
|
+
export function promptKeys(p: PanePrompt, n: number): string[] {
|
|
214
|
+
const from = Math.max(0, p.options.findIndex((o) => o.selected));
|
|
215
|
+
const to = Math.max(0, Math.min(p.options.length - 1, n - 1));
|
|
216
|
+
const move = to > from ? Array(to - from).fill('Down') : Array(from - to).fill('Up');
|
|
217
|
+
return [...move, 'Enter'];
|
|
218
|
+
}
|
|
@@ -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
|
|
|
5
6
|
export interface AiSessionSummary {
|
|
6
7
|
tmuxName: string;
|
|
@@ -26,6 +27,12 @@ export interface AiSessionSummary {
|
|
|
26
27
|
// Which file this row was built from, so opening the session resolves to
|
|
27
28
|
// the same one rather than re-racing the other panes.
|
|
28
29
|
transcript: string;
|
|
30
|
+
/**
|
|
31
|
+
* A menu the agent's TUI is showing (permission, plan approval, question),
|
|
32
|
+
* read from the pane — the transcript never records most of these. Absent
|
|
33
|
+
* from agents before 1.9.
|
|
34
|
+
*/
|
|
35
|
+
prompt?: PanePrompt | null;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
// Replay cap. Opening a month-old session must not flood the socket, and a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indianic/dialout",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Dialout agent — reach your office, home or build machine from any browser or phone. Remote tmux terminals, live port checks, filesystem browsing, process control and HTTP tunnels. The agent dials out: no inbound ports, no VPN, no port forwarding.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"dialout": "./bin/devdash-agent.js"
|