@robota-sdk/agent-transport 3.0.0-beta.64

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 (183) hide show
  1. package/LICENSE +21 -0
  2. package/dist/node/headless/index.cjs +1 -0
  3. package/dist/node/headless/index.d.ts +2 -0
  4. package/dist/node/headless/index.js +1 -0
  5. package/dist/node/headless-CWEpJXFK.js +7 -0
  6. package/dist/node/headless-CWEpJXFK.js.map +1 -0
  7. package/dist/node/headless-CsZFelG9.cjs +6 -0
  8. package/dist/node/http/index.cjs +1 -0
  9. package/dist/node/http/index.d.ts +2 -0
  10. package/dist/node/http/index.js +1 -0
  11. package/dist/node/http-CM3TJhrF.cjs +1 -0
  12. package/dist/node/http-DwO1AHG-.js +2 -0
  13. package/dist/node/http-DwO1AHG-.js.map +1 -0
  14. package/dist/node/index--Ti9NzQX.d.ts +64 -0
  15. package/dist/node/index--Ti9NzQX.d.ts.map +1 -0
  16. package/dist/node/index-B_rcr14p.d.ts +47 -0
  17. package/dist/node/index-B_rcr14p.d.ts.map +1 -0
  18. package/dist/node/index-C9LWCL4l.d.ts +34 -0
  19. package/dist/node/index-C9LWCL4l.d.ts.map +1 -0
  20. package/dist/node/index-CAr3ioVh.d.ts +64 -0
  21. package/dist/node/index-CAr3ioVh.d.ts.map +1 -0
  22. package/dist/node/index-CEs25wVk.d.ts +213 -0
  23. package/dist/node/index-CEs25wVk.d.ts.map +1 -0
  24. package/dist/node/index-CvXLpjJO.d.ts +213 -0
  25. package/dist/node/index-CvXLpjJO.d.ts.map +1 -0
  26. package/dist/node/index-D34WUfFH.d.ts +26 -0
  27. package/dist/node/index-D34WUfFH.d.ts.map +1 -0
  28. package/dist/node/index-Y0zHb1Bz.d.ts +47 -0
  29. package/dist/node/index-Y0zHb1Bz.d.ts.map +1 -0
  30. package/dist/node/index-k3TUjA-T.d.ts +26 -0
  31. package/dist/node/index-k3TUjA-T.d.ts.map +1 -0
  32. package/dist/node/index-nBlMTFkZ.d.ts +34 -0
  33. package/dist/node/index-nBlMTFkZ.d.ts.map +1 -0
  34. package/dist/node/index.cjs +1 -0
  35. package/dist/node/index.d.ts +6 -0
  36. package/dist/node/index.js +1 -0
  37. package/dist/node/mcp/index.cjs +1 -0
  38. package/dist/node/mcp/index.d.ts +2 -0
  39. package/dist/node/mcp/index.js +1 -0
  40. package/dist/node/mcp-BXBwF6Wu.js +2 -0
  41. package/dist/node/mcp-BXBwF6Wu.js.map +1 -0
  42. package/dist/node/mcp-DcHuGokt.cjs +1 -0
  43. package/dist/node/tui/index.cjs +1 -0
  44. package/dist/node/tui/index.d.ts +2 -0
  45. package/dist/node/tui/index.js +1 -0
  46. package/dist/node/tui-CeD_6rSo.cjs +24 -0
  47. package/dist/node/tui-zmDTPk4b.js +25 -0
  48. package/dist/node/tui-zmDTPk4b.js.map +1 -0
  49. package/dist/node/ws/index.cjs +1 -0
  50. package/dist/node/ws/index.d.ts +2 -0
  51. package/dist/node/ws/index.js +1 -0
  52. package/dist/node/ws-B-oRccFl.js +2 -0
  53. package/dist/node/ws-B-oRccFl.js.map +1 -0
  54. package/dist/node/ws-COnIgnmn.cjs +1 -0
  55. package/package.json +141 -0
  56. package/src/headless/__tests__/headless-runner-initialization.test.ts +45 -0
  57. package/src/headless/__tests__/headless-runner.test.ts +484 -0
  58. package/src/headless/__tests__/headless-skill-activation.integration.test.ts +430 -0
  59. package/src/headless/__tests__/headless-transport.test.ts +268 -0
  60. package/src/headless/headless-runner.ts +141 -0
  61. package/src/headless/headless-stream-json.ts +142 -0
  62. package/src/headless/headless-transport.ts +43 -0
  63. package/src/headless/index.ts +4 -0
  64. package/src/http/__tests__/http-transport.test.ts +55 -0
  65. package/src/http/__tests__/routes.test.ts +168 -0
  66. package/src/http/http-transport.ts +42 -0
  67. package/src/http/index.ts +4 -0
  68. package/src/http/routes.ts +151 -0
  69. package/src/index.ts +5 -0
  70. package/src/mcp/__tests__/mcp-server.test.ts +66 -0
  71. package/src/mcp/__tests__/mcp-transport.test.ts +46 -0
  72. package/src/mcp/index.ts +4 -0
  73. package/src/mcp/mcp-server.ts +162 -0
  74. package/src/mcp/mcp-transport.ts +48 -0
  75. package/src/tui/App.tsx +478 -0
  76. package/src/tui/BackgroundTaskPanel.tsx +34 -0
  77. package/src/tui/CjkTextInput.tsx +204 -0
  78. package/src/tui/ConfirmPrompt.tsx +69 -0
  79. package/src/tui/ExecutionWorkspaceDetailPane.tsx +62 -0
  80. package/src/tui/ExecutionWorkspaceSwitcher.tsx +185 -0
  81. package/src/tui/InkTerminal.ts +42 -0
  82. package/src/tui/InputArea.tsx +298 -0
  83. package/src/tui/InteractivePrompt.tsx +57 -0
  84. package/src/tui/ListPicker.tsx +94 -0
  85. package/src/tui/MenuSelect.tsx +103 -0
  86. package/src/tui/MessageList.tsx +282 -0
  87. package/src/tui/PermissionPrompt.tsx +84 -0
  88. package/src/tui/PluginTUI.tsx +256 -0
  89. package/src/tui/SessionPicker.tsx +66 -0
  90. package/src/tui/SessionStatusBar.tsx +66 -0
  91. package/src/tui/SlashAutocomplete.tsx +110 -0
  92. package/src/tui/StatusBar.tsx +213 -0
  93. package/src/tui/StreamingIndicator.tsx +91 -0
  94. package/src/tui/TextPrompt.tsx +80 -0
  95. package/src/tui/ToolCommandOutput.tsx +37 -0
  96. package/src/tui/ToolDiffBlock.tsx +30 -0
  97. package/src/tui/TransportTUI.tsx +116 -0
  98. package/src/tui/UpdateNotice.tsx +14 -0
  99. package/src/tui/UsageSummaryEntry.tsx +38 -0
  100. package/src/tui/WaveText.tsx +44 -0
  101. package/src/tui/__tests__/InteractivePrompt.test.tsx +82 -0
  102. package/src/tui/__tests__/ListPicker.test.tsx +159 -0
  103. package/src/tui/__tests__/MenuSelect.test.tsx +103 -0
  104. package/src/tui/__tests__/PluginTUI.test.tsx +167 -0
  105. package/src/tui/__tests__/SlashAutocomplete.test.tsx +140 -0
  106. package/src/tui/__tests__/TextPrompt.test.tsx +98 -0
  107. package/src/tui/__tests__/UpdateNotice.test.tsx +15 -0
  108. package/src/tui/__tests__/abort-after-permission.test.tsx +169 -0
  109. package/src/tui/__tests__/abort-streaming-e2e.test.tsx +183 -0
  110. package/src/tui/__tests__/background-task-panel.test.tsx +53 -0
  111. package/src/tui/__tests__/background-task-row-format.test.ts +59 -0
  112. package/src/tui/__tests__/cjk-text-input-flow.test.ts +109 -0
  113. package/src/tui/__tests__/cjk-text-input.test.ts +191 -0
  114. package/src/tui/__tests__/command-effect-handler.test.ts +128 -0
  115. package/src/tui/__tests__/command-output-summary.test.ts +95 -0
  116. package/src/tui/__tests__/compact-event-bridge.test.ts +20 -0
  117. package/src/tui/__tests__/confirm-permission-flow.test.ts +91 -0
  118. package/src/tui/__tests__/confirm-prompt.test.tsx +87 -0
  119. package/src/tui/__tests__/execution-workspace-switcher.test.tsx +110 -0
  120. package/src/tui/__tests__/execution-workspace-view-model.test.ts +93 -0
  121. package/src/tui/__tests__/fixtures/provider-setup-prompt-driver.tsx +122 -0
  122. package/src/tui/__tests__/input-area-flow.test.ts +152 -0
  123. package/src/tui/__tests__/message-list-rendering.test.tsx +353 -0
  124. package/src/tui/__tests__/model-change-side-effect.test.ts +91 -0
  125. package/src/tui/__tests__/prompt-queue.test.tsx +255 -0
  126. package/src/tui/__tests__/provider-setup-pty-e2e.test.ts +233 -0
  127. package/src/tui/__tests__/render-markdown.test.ts +72 -0
  128. package/src/tui/__tests__/selection-flow.test.ts +61 -0
  129. package/src/tui/__tests__/slash-routing-effects.test.ts +225 -0
  130. package/src/tui/__tests__/status-activity.test.ts +71 -0
  131. package/src/tui/__tests__/status-bar.test.tsx +157 -0
  132. package/src/tui/__tests__/streaming-indicator.test.tsx +137 -0
  133. package/src/tui/__tests__/text-prompt-flow.test.ts +77 -0
  134. package/src/tui/__tests__/tui-state-manager.test.ts +401 -0
  135. package/src/tui/background-task-row-format.ts +52 -0
  136. package/src/tui/command-output-summary.ts +122 -0
  137. package/src/tui/execution-workspace-view-model.ts +123 -0
  138. package/src/tui/flows/cjk-text-input-flow.ts +285 -0
  139. package/src/tui/flows/confirm-prompt-flow.ts +45 -0
  140. package/src/tui/flows/input-area-flow.ts +186 -0
  141. package/src/tui/flows/permission-prompt-flow.ts +76 -0
  142. package/src/tui/flows/selection-flow.ts +126 -0
  143. package/src/tui/flows/text-prompt-flow.ts +98 -0
  144. package/src/tui/hooks/command-effect-handler.ts +98 -0
  145. package/src/tui/hooks/command-effect-queue.ts +39 -0
  146. package/src/tui/hooks/model-change-side-effect.ts +63 -0
  147. package/src/tui/hooks/side-effects-types.ts +38 -0
  148. package/src/tui/hooks/use-interactive-session-init.ts +50 -0
  149. package/src/tui/hooks/useAutocomplete.ts +85 -0
  150. package/src/tui/hooks/useInteractiveSession.ts +273 -0
  151. package/src/tui/hooks/usePermissionQueue.ts +51 -0
  152. package/src/tui/hooks/usePluginCallbacks.ts +30 -0
  153. package/src/tui/hooks/usePluginScreenData.ts +84 -0
  154. package/src/tui/hooks/useSideEffects.ts +210 -0
  155. package/src/tui/hooks/useSlashRouting.ts +117 -0
  156. package/src/tui/hooks/useStatusLineSettings.ts +35 -0
  157. package/src/tui/index.ts +3 -0
  158. package/src/tui/plugin-tui-handlers.ts +163 -0
  159. package/src/tui/render-markdown.ts +129 -0
  160. package/src/tui/render.tsx +60 -0
  161. package/src/tui/status-activity.ts +63 -0
  162. package/src/tui/tui-cli-adapter-context.tsx +12 -0
  163. package/src/tui/tui-cli-adapter.ts +25 -0
  164. package/src/tui/tui-state-manager.ts +225 -0
  165. package/src/tui/tui-transport.ts +32 -0
  166. package/src/tui/types.ts +14 -0
  167. package/src/tui/utils/__tests__/edit-diff.test.ts +426 -0
  168. package/src/tui/utils/__tests__/paste-detection.test.ts +116 -0
  169. package/src/tui/utils/__tests__/paste-labels.test.ts +46 -0
  170. package/src/tui/utils/__tests__/tool-call-extractor.test.ts +227 -0
  171. package/src/tui/utils/__tests__/tool-diff-summary.test.ts +104 -0
  172. package/src/tui/utils/edit-diff.ts +152 -0
  173. package/src/tui/utils/paste-labels.ts +9 -0
  174. package/src/tui/utils/tool-call-extractor.ts +91 -0
  175. package/src/tui/utils/tool-diff-summary.ts +75 -0
  176. package/src/ws/__tests__/ws-handler.test.ts +407 -0
  177. package/src/ws/__tests__/ws-transport.test.ts +53 -0
  178. package/src/ws/index.ts +13 -0
  179. package/src/ws/ws-background-messages.ts +170 -0
  180. package/src/ws/ws-handler.ts +279 -0
  181. package/src/ws/ws-protocol.ts +76 -0
  182. package/src/ws/ws-transport-configurable.ts +123 -0
  183. package/src/ws/ws-transport.ts +42 -0
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ applySelectionInput,
4
+ createSelectionFlowState,
5
+ getDirectionalSelectionInputAction,
6
+ getVerticalSelectionInputAction,
7
+ } from '../flows/selection-flow.js';
8
+
9
+ describe('selection flow', () => {
10
+ it('Given first item selected When previous is applied Then selection stays bounded', () => {
11
+ const result = applySelectionInput(createSelectionFlowState(), 'previous', { itemCount: 3 });
12
+
13
+ expect(result.state.selectedIndex).toBe(0);
14
+ expect(result.effect).toEqual({ type: 'none' });
15
+ });
16
+
17
+ it('Given last item selected When next is applied Then selection stays bounded', () => {
18
+ const state = { selectedIndex: 2, scrollOffset: 0, resolved: false };
19
+
20
+ const result = applySelectionInput(state, 'next', { itemCount: 3 });
21
+
22
+ expect(result.state.selectedIndex).toBe(2);
23
+ });
24
+
25
+ it('Given wrapping selection When previous from first is applied Then it wraps to last', () => {
26
+ const result = applySelectionInput(createSelectionFlowState(), 'previous', {
27
+ itemCount: 3,
28
+ wrap: true,
29
+ });
30
+
31
+ expect(result.state.selectedIndex).toBe(2);
32
+ });
33
+
34
+ it('Given max visible window When moving below viewport Then scroll offset follows', () => {
35
+ const state = applySelectionInput(createSelectionFlowState(), 'next', {
36
+ itemCount: 4,
37
+ maxVisible: 2,
38
+ }).state;
39
+
40
+ const result = applySelectionInput(state, 'next', { itemCount: 4, maxVisible: 2 });
41
+
42
+ expect(result.state).toMatchObject({ selectedIndex: 2, scrollOffset: 1 });
43
+ });
44
+
45
+ it('Given selected item When select is applied Then selected index is emitted once', () => {
46
+ const state = { selectedIndex: 1, scrollOffset: 0, resolved: false };
47
+
48
+ const selected = applySelectionInput(state, 'select', { itemCount: 3 });
49
+ const ignored = applySelectionInput(selected.state, 'select', { itemCount: 3 });
50
+
51
+ expect(selected.effect).toEqual({ type: 'select', index: 1 });
52
+ expect(ignored.effect).toEqual({ type: 'none' });
53
+ });
54
+
55
+ it('Given raw key info When mapped Then vertical and directional actions are produced', () => {
56
+ expect(getVerticalSelectionInputAction({ downArrow: true })).toBe('next');
57
+ expect(getVerticalSelectionInputAction({ escape: true })).toBe('cancel');
58
+ expect(getDirectionalSelectionInputAction({ leftArrow: true })).toBe('previous');
59
+ expect(getDirectionalSelectionInputAction({ rightArrow: true })).toBe('next');
60
+ });
61
+ });
@@ -0,0 +1,225 @@
1
+ import { homedir } from 'node:os';
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { describe, expect, it } from 'vitest';
6
+ import { afterEach, vi } from 'vitest';
7
+ import {
8
+ CommandRegistry,
9
+ BundlePluginLoader,
10
+ PluginCommandSource,
11
+ } from '@robota-sdk/agent-framework';
12
+ import type { ICommandInteraction, IInteractiveSession } from '@robota-sdk/agent-framework';
13
+ import { TuiStateManager } from '../tui-state-manager.js';
14
+ import { applySystemCommandResult } from '../hooks/useSlashRouting.js';
15
+ import { CommandEffectQueue } from '../hooks/command-effect-queue.js';
16
+
17
+ const PLUGIN_SOURCE_NAME = 'plugin';
18
+
19
+ function reloadPluginCommandSource(registry: CommandRegistry): void {
20
+ const pluginsDir = join(process.env.HOME ?? homedir(), '.robota', 'plugins');
21
+ const loader = new BundlePluginLoader(pluginsDir);
22
+ try {
23
+ // allow-fallback: test helper — empty registry on load error is safe
24
+ const plugins = loader.loadPluginsSync();
25
+ if (plugins.length === 0) {
26
+ registry.replaceSource(PLUGIN_SOURCE_NAME);
27
+ } else {
28
+ registry.replaceSource(PLUGIN_SOURCE_NAME, new PluginCommandSource(plugins));
29
+ }
30
+ } catch {
31
+ // allow-fallback: test helper — empty registry on load error is safe
32
+ registry.replaceSource(PLUGIN_SOURCE_NAME);
33
+ }
34
+ }
35
+
36
+ describe('applySystemCommandResult', () => {
37
+ afterEach(() => {
38
+ vi.unstubAllEnvs();
39
+ });
40
+
41
+ function createRegistry(): CommandRegistry {
42
+ return new CommandRegistry();
43
+ }
44
+
45
+ function legacyCommandField(suffix: 'Interaction' | 'Effects'): string {
46
+ return `_pendingCommand${suffix}`;
47
+ }
48
+
49
+ it('stores statusline settings patch as a CLI side effect', () => {
50
+ const session = {
51
+ getContextState: () => ({ usedPercentage: 0, usedTokens: 0, maxTokens: 0 }),
52
+ } as unknown as IInteractiveSession;
53
+ const manager = new TuiStateManager();
54
+ const queue = new CommandEffectQueue();
55
+
56
+ applySystemCommandResult(
57
+ {
58
+ success: true,
59
+ message: 'Status line disabled.',
60
+ effects: [{ type: 'statusline-settings-patch', patch: { enabled: false } }],
61
+ },
62
+ session,
63
+ createRegistry(),
64
+ manager,
65
+ queue,
66
+ );
67
+
68
+ expect(queue.drain()).toEqual({
69
+ type: 'effects',
70
+ effects: [{ type: 'statusline-settings-patch', patch: { enabled: false } }],
71
+ });
72
+ expect(Object.hasOwn(session, legacyCommandField('Effects'))).toBe(false);
73
+ });
74
+
75
+ it('stores generic command interactions without interpreting command-specific data', () => {
76
+ const session = {
77
+ getContextState: () => ({ usedPercentage: 0, usedTokens: 0, maxTokens: 0 }),
78
+ } as unknown as IInteractiveSession;
79
+ const manager = new TuiStateManager();
80
+ const queue = new CommandEffectQueue();
81
+ const interaction: ICommandInteraction = {
82
+ prompt: {
83
+ kind: 'choice',
84
+ title: 'Change provider?',
85
+ options: [
86
+ { value: 'yes', label: 'Yes' },
87
+ { value: 'no', label: 'No' },
88
+ ],
89
+ },
90
+ submit: () => ({ success: true, message: 'done' }),
91
+ };
92
+
93
+ applySystemCommandResult(
94
+ {
95
+ success: true,
96
+ message: 'Switch provider?',
97
+ interaction,
98
+ },
99
+ session,
100
+ createRegistry(),
101
+ manager,
102
+ queue,
103
+ );
104
+
105
+ expect(queue.drain()).toEqual({ type: 'interaction', interaction });
106
+ expect(Object.hasOwn(session, legacyCommandField('Interaction'))).toBe(false);
107
+ });
108
+
109
+ it('stores host command side effects', () => {
110
+ const session = {
111
+ getContextState: () => ({ usedPercentage: 0, usedTokens: 0, maxTokens: 0 }),
112
+ } as unknown as IInteractiveSession;
113
+ const manager = new TuiStateManager();
114
+ const queue = new CommandEffectQueue();
115
+
116
+ applySystemCommandResult(
117
+ {
118
+ success: true,
119
+ message: 'Opening plugin manager...',
120
+ effects: [{ type: 'plugin-tui-requested' }],
121
+ },
122
+ session,
123
+ createRegistry(),
124
+ manager,
125
+ queue,
126
+ );
127
+
128
+ expect(queue.drain()).toEqual({
129
+ type: 'effects',
130
+ effects: [{ type: 'plugin-tui-requested' }],
131
+ });
132
+ expect(Object.hasOwn(session, legacyCommandField('Effects'))).toBe(false);
133
+ });
134
+
135
+ it('applies conversation history clearing immediately before adding the command result', () => {
136
+ const session = {
137
+ getContextState: () => ({ usedPercentage: 0, usedTokens: 0, maxTokens: 0 }),
138
+ } as unknown as IInteractiveSession;
139
+ const manager = new TuiStateManager();
140
+ const queue = new CommandEffectQueue();
141
+ manager.addEntry({
142
+ id: 'old',
143
+ timestamp: new Date('2026-05-03T00:00:00.000Z'),
144
+ category: 'chat',
145
+ type: 'user',
146
+ data: { role: 'user', content: 'old message' },
147
+ });
148
+
149
+ applySystemCommandResult(
150
+ {
151
+ success: true,
152
+ message: 'Conversation cleared.',
153
+ effects: [{ type: 'conversation-history-cleared' }],
154
+ },
155
+ session,
156
+ createRegistry(),
157
+ manager,
158
+ queue,
159
+ );
160
+
161
+ expect(manager.history).toHaveLength(1);
162
+ expect(manager.history[0]?.type).toBe('system');
163
+ expect(manager.history[0]?.data).toMatchObject({ content: 'Conversation cleared.' });
164
+ expect(queue.drain()).toBeUndefined();
165
+ expect(Object.hasOwn(session, legacyCommandField('Effects'))).toBe(false);
166
+ });
167
+
168
+ it('reloads plugin command source immediately when requested', () => {
169
+ const home = mkdtempSync(join(tmpdir(), 'robota-plugin-reload-'));
170
+ const pluginDir = join(
171
+ home,
172
+ '.robota',
173
+ 'plugins',
174
+ 'cache',
175
+ 'community',
176
+ 'fresh-plugin',
177
+ '1.0.0',
178
+ );
179
+ mkdirSync(join(pluginDir, '.claude-plugin'), { recursive: true });
180
+ mkdirSync(join(pluginDir, 'skills', 'fresh-skill'), { recursive: true });
181
+ writeFileSync(
182
+ join(pluginDir, '.claude-plugin', 'plugin.json'),
183
+ JSON.stringify({
184
+ name: 'fresh-plugin',
185
+ version: '1.0.0',
186
+ description: 'Fresh plugin',
187
+ features: { skills: true },
188
+ }),
189
+ 'utf8',
190
+ );
191
+ writeFileSync(
192
+ join(pluginDir, 'skills', 'fresh-skill', 'SKILL.md'),
193
+ '---\ndescription: Fresh skill\n---\n# Fresh Skill\n',
194
+ 'utf8',
195
+ );
196
+ vi.stubEnv('HOME', home);
197
+ const session = {
198
+ getContextState: () => ({ usedPercentage: 0, usedTokens: 0, maxTokens: 0 }),
199
+ } as unknown as IInteractiveSession;
200
+ const registry = createRegistry();
201
+ const queue = new CommandEffectQueue();
202
+ registry.addSource({
203
+ name: 'plugin',
204
+ getCommands: () => [{ name: 'stale-skill', description: 'Stale', source: 'plugin' }],
205
+ });
206
+ const manager = new TuiStateManager();
207
+
208
+ applySystemCommandResult(
209
+ {
210
+ success: true,
211
+ message: 'Reloaded 1 plugin resource.',
212
+ effects: [{ type: 'plugin-registry-reload-requested' }],
213
+ },
214
+ session,
215
+ registry,
216
+ manager,
217
+ queue,
218
+ reloadPluginCommandSource,
219
+ );
220
+
221
+ expect(registry.getCommands().map((command) => command.name)).toEqual(['fresh-skill']);
222
+ expect(queue.drain()).toBeUndefined();
223
+ expect(Object.hasOwn(session, legacyCommandField('Effects'))).toBe(false);
224
+ });
225
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatStatusActivity } from '../status-activity.js';
3
+
4
+ describe('formatStatusActivity', () => {
5
+ it('prioritizes running tools over thinking, background work, and queued prompts', () => {
6
+ const activity = formatStatusActivity({
7
+ isThinking: true,
8
+ activeToolCount: 2,
9
+ activeBackgroundTaskCount: 3,
10
+ hasPendingPrompt: true,
11
+ });
12
+
13
+ expect(activity.kind).toBe('tools');
14
+ expect(activity.label).toBe('Tools x2');
15
+ expect(activity.color).toBe('cyan');
16
+ expect(activity.segments).toEqual(['queued']);
17
+ expect(activity.text).toBe('Tools x2 · queued');
18
+ });
19
+
20
+ it('shows thinking as the primary model waiting state', () => {
21
+ const activity = formatStatusActivity({
22
+ isThinking: true,
23
+ activeToolCount: 0,
24
+ activeBackgroundTaskCount: 0,
25
+ hasPendingPrompt: false,
26
+ });
27
+
28
+ expect(activity.kind).toBe('thinking');
29
+ expect(activity.label).toBe('Thinking');
30
+ expect(activity.segments).toEqual([]);
31
+ });
32
+
33
+ it('shows background activity when foreground work is idle', () => {
34
+ const activity = formatStatusActivity({
35
+ isThinking: false,
36
+ activeToolCount: 0,
37
+ activeBackgroundTaskCount: 1,
38
+ hasPendingPrompt: false,
39
+ });
40
+
41
+ expect(activity.kind).toBe('background');
42
+ expect(activity.label).toBe('Background x1');
43
+ expect(activity.color).toBe('cyan');
44
+ });
45
+
46
+ it('shows queued prompt before idle when no work is active', () => {
47
+ const activity = formatStatusActivity({
48
+ isThinking: false,
49
+ activeToolCount: 0,
50
+ activeBackgroundTaskCount: 0,
51
+ hasPendingPrompt: true,
52
+ });
53
+
54
+ expect(activity.kind).toBe('queued');
55
+ expect(activity.label).toBe('Queued');
56
+ expect(activity.color).toBe('yellow');
57
+ });
58
+
59
+ it('keeps idle compact and dim', () => {
60
+ const activity = formatStatusActivity({
61
+ isThinking: false,
62
+ activeToolCount: 0,
63
+ activeBackgroundTaskCount: 0,
64
+ hasPendingPrompt: false,
65
+ });
66
+
67
+ expect(activity.kind).toBe('idle');
68
+ expect(activity.text).toBe('Idle');
69
+ expect(activity.color).toBe('gray');
70
+ });
71
+ });
@@ -0,0 +1,157 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, it, expect } from 'vitest';
4
+ import StatusBar from '../StatusBar.js';
5
+
6
+ describe('StatusBar', () => {
7
+ const baseProps = {
8
+ permissionMode: 'default' as const,
9
+ modelName: 'test-model',
10
+ sessionId: 'sess-1',
11
+ isThinking: false,
12
+ activeToolCount: 0,
13
+ activeBackgroundTaskCount: 0,
14
+ hasPendingPrompt: false,
15
+ contextPercentage: 10,
16
+ contextUsedTokens: 1000,
17
+ contextMaxTokens: 200000,
18
+ };
19
+
20
+ it('renders without session name', () => {
21
+ const { lastFrame } = render(<StatusBar {...baseProps} />);
22
+ const frame = lastFrame()!;
23
+ expect(frame).toContain('test-model');
24
+ expect(frame).not.toContain('Mode: default');
25
+ });
26
+
27
+ it('hides default permission mode', () => {
28
+ const { lastFrame } = render(<StatusBar {...baseProps} permissionMode="default" />);
29
+ const frame = lastFrame()!;
30
+ expect(frame).not.toContain('Mode:');
31
+ expect(frame).not.toContain('default');
32
+ });
33
+
34
+ it('shows non-default permission modes', () => {
35
+ for (const permissionMode of ['plan', 'acceptEdits', 'bypassPermissions'] as const) {
36
+ const { lastFrame, unmount } = render(
37
+ <StatusBar {...baseProps} permissionMode={permissionMode} />,
38
+ );
39
+ const frame = lastFrame()!;
40
+ expect(frame).toContain('Mode:');
41
+ expect(frame).toContain(permissionMode);
42
+ unmount();
43
+ }
44
+ });
45
+
46
+ it('renders session name when provided', () => {
47
+ const { lastFrame } = render(<StatusBar {...baseProps} sessionName="my-feature" />);
48
+ const frame = lastFrame()!;
49
+ expect(frame).toContain('my-feature');
50
+ });
51
+
52
+ it('does not show session name when undefined', () => {
53
+ const { lastFrame } = render(<StatusBar {...baseProps} sessionName={undefined} />);
54
+ const frame = lastFrame()!;
55
+ // Should not have extra separator for missing name
56
+ expect(frame).not.toContain('my-feature');
57
+ });
58
+
59
+ it('renders model name', () => {
60
+ const { lastFrame } = render(<StatusBar {...baseProps} />);
61
+ const frame = lastFrame()!;
62
+ expect(frame).toContain('test-model');
63
+ });
64
+
65
+ it('renders provider display name and model when provided', () => {
66
+ const { lastFrame } = render(<StatusBar {...baseProps} providerDisplayName="Anthropic" />);
67
+ const frame = lastFrame()!;
68
+ expect(frame).toContain('Anthropic');
69
+ expect(frame).toContain('test-model');
70
+ });
71
+
72
+ it('does not render message count in the status bar', () => {
73
+ const { lastFrame } = render(<StatusBar {...baseProps} />);
74
+ const frame = lastFrame()!;
75
+ expect(frame).not.toContain('msgs:');
76
+ });
77
+
78
+ it('shows thinking indicator when isThinking is true', () => {
79
+ const { lastFrame } = render(<StatusBar {...baseProps} isThinking={true} />);
80
+ const frame = lastFrame()!;
81
+ expect(frame).not.toContain('Activity:');
82
+ expect(frame).toContain('Thinking');
83
+ expect(frame.indexOf('Thinking')).toBeLessThan(frame.indexOf('test-model'));
84
+ });
85
+
86
+ it('does not duplicate thinking state in secondary status text', () => {
87
+ const { lastFrame } = render(<StatusBar {...baseProps} isThinking={true} />);
88
+ const frame = lastFrame()!;
89
+ expect(frame).toContain('Thinking');
90
+ expect(frame).not.toContain('thinking...');
91
+ expect(frame).not.toContain('msgs:');
92
+ });
93
+
94
+ it('hides the lower-right prompt-processing indicator while idle', () => {
95
+ const { lastFrame } = render(<StatusBar {...baseProps} isThinking={false} />);
96
+ const frame = lastFrame()!;
97
+ expect(frame).not.toContain('thinking...');
98
+ });
99
+
100
+ it('prioritizes tool activity in the primary scan path', () => {
101
+ const { lastFrame } = render(
102
+ <StatusBar
103
+ {...baseProps}
104
+ isThinking={true}
105
+ activeToolCount={2}
106
+ activeBackgroundTaskCount={1}
107
+ hasPendingPrompt={true}
108
+ />,
109
+ );
110
+ const frame = lastFrame()!;
111
+ expect(frame).not.toContain('Activity:');
112
+ expect(frame).toContain('Tools x2');
113
+ expect(frame).toContain('queued');
114
+ expect(frame).not.toContain('thinking...');
115
+ expect(frame.indexOf('Tools x2')).toBeLessThan(frame.indexOf('test-model'));
116
+ expect(frame).not.toContain('Thinking...');
117
+ });
118
+
119
+ it('shows background activity when no foreground execution is active', () => {
120
+ const { lastFrame } = render(<StatusBar {...baseProps} activeBackgroundTaskCount={3} />);
121
+ const frame = lastFrame()!;
122
+ expect(frame).toContain('Background x3');
123
+ expect(frame.indexOf('Background x3')).toBeLessThan(frame.indexOf('test-model'));
124
+ });
125
+
126
+ it('keeps the activity segment compact for narrow terminals', () => {
127
+ const { lastFrame } = render(
128
+ <StatusBar
129
+ {...baseProps}
130
+ isThinking={true}
131
+ activeToolCount={12}
132
+ activeBackgroundTaskCount={9}
133
+ hasPendingPrompt={true}
134
+ />,
135
+ );
136
+ const frame = lastFrame()!;
137
+ const firstLine = frame.split('\n')[1] ?? '';
138
+ const activityEnd = firstLine.indexOf('test-model');
139
+ const activitySegment = firstLine.slice(0, activityEnd);
140
+ expect(activitySegment).toContain('Tools x12');
141
+ expect(activitySegment.length).toBeLessThanOrEqual(40);
142
+ });
143
+
144
+ it('renders git branch when provided', () => {
145
+ const { lastFrame } = render(<StatusBar {...baseProps} gitBranch="feat/status-line" />);
146
+ const frame = lastFrame()!;
147
+ expect(frame).toContain('feat/status-line');
148
+ });
149
+
150
+ it('does not render git branch when visibility is disabled', () => {
151
+ const { lastFrame } = render(
152
+ <StatusBar {...baseProps} gitBranch="feat/status-line" showGitBranch={false} />,
153
+ );
154
+ const frame = lastFrame()!;
155
+ expect(frame).not.toContain('feat/status-line');
156
+ });
157
+ });
@@ -0,0 +1,137 @@
1
+ import React from 'react';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { render } from 'ink-testing-library';
4
+ import StreamingIndicator from '../StreamingIndicator.js';
5
+
6
+ describe('StreamingIndicator', () => {
7
+ it('renders empty when no tools and no text', () => {
8
+ const { lastFrame } = render(<StreamingIndicator text="" activeTools={[]} />);
9
+ expect(lastFrame()).not.toContain('Thinking');
10
+ });
11
+
12
+ it('shows a generic thinking state when foreground work has no text or tools yet', () => {
13
+ const { lastFrame } = render(<StreamingIndicator text="" activeTools={[]} isThinking={true} />);
14
+
15
+ expect(lastFrame()).toContain('Thinking...');
16
+ });
17
+
18
+ it('shows Tools: section with running tool', () => {
19
+ const { lastFrame } = render(
20
+ <StreamingIndicator
21
+ text=""
22
+ activeTools={[{ toolName: 'Bash', firstArg: 'ls -la', isRunning: true }]}
23
+ />,
24
+ );
25
+ const frame = lastFrame()!;
26
+ expect(frame).toContain('Tools:');
27
+ expect(frame).toContain('Bash(ls -la)');
28
+ });
29
+
30
+ it('shows ⟳ for running and ✓ for completed tools', () => {
31
+ const { lastFrame } = render(
32
+ <StreamingIndicator
33
+ text=""
34
+ activeTools={[
35
+ { toolName: 'Read', firstArg: '/src/index.ts', isRunning: false },
36
+ { toolName: 'Bash', firstArg: 'ls', isRunning: true },
37
+ ]}
38
+ />,
39
+ );
40
+ const frame = lastFrame()!;
41
+ expect(frame).toContain('✓ Read(/src/index.ts)');
42
+ expect(frame).toContain('⟳ Bash(ls)');
43
+ });
44
+
45
+ it('shows Robota: section with streaming text', () => {
46
+ const { lastFrame } = render(<StreamingIndicator text="Hello world" activeTools={[]} />);
47
+ const frame = lastFrame()!;
48
+ expect(frame).toContain('Robota:');
49
+ expect(frame).toContain('Hello world');
50
+ expect(frame).not.toContain('Tools:');
51
+ });
52
+
53
+ it('preserves CJK and emoji text in streaming output', () => {
54
+ const text = '긴 한국어 응답과 emoji 🎉 를 스트리밍합니다';
55
+ const { lastFrame } = render(<StreamingIndicator text={text} activeTools={[]} />);
56
+
57
+ expect(lastFrame()).toContain(text);
58
+ });
59
+
60
+ it('shows Tools: before Robota: when both present', () => {
61
+ const { lastFrame } = render(
62
+ <StreamingIndicator
63
+ text="Analyzing..."
64
+ activeTools={[{ toolName: 'Read', firstArg: 'file.ts', isRunning: false }]}
65
+ />,
66
+ );
67
+ const frame = lastFrame()!;
68
+ const toolsIndex = frame.indexOf('Tools:');
69
+ const robotaIndex = frame.indexOf('Robota:');
70
+ expect(toolsIndex).toBeGreaterThanOrEqual(0);
71
+ expect(robotaIndex).toBeGreaterThanOrEqual(0);
72
+ expect(toolsIndex).toBeLessThan(robotaIndex);
73
+ });
74
+
75
+ it('does not show Thinking... when tools are active', () => {
76
+ const { lastFrame } = render(
77
+ <StreamingIndicator
78
+ text=""
79
+ activeTools={[{ toolName: 'Glob', firstArg: '**/*.md', isRunning: true }]}
80
+ />,
81
+ );
82
+ expect(lastFrame()).not.toContain('Thinking...');
83
+ });
84
+
85
+ it('does not show Thinking... when text is present', () => {
86
+ const { lastFrame } = render(
87
+ <StreamingIndicator text="Some response" activeTools={[]} isThinking={true} />,
88
+ );
89
+ expect(lastFrame()).not.toContain('Thinking...');
90
+ });
91
+
92
+ it('shows multiple tools in order', () => {
93
+ const { lastFrame } = render(
94
+ <StreamingIndicator
95
+ text=""
96
+ activeTools={[
97
+ { toolName: 'Read', firstArg: 'a.ts', isRunning: false },
98
+ { toolName: 'Bash', firstArg: 'echo hi', isRunning: false },
99
+ { toolName: 'Glob', firstArg: '**/*.md', isRunning: true },
100
+ ]}
101
+ />,
102
+ );
103
+ const frame = lastFrame()!;
104
+ const readIdx = frame.indexOf('Read(a.ts)');
105
+ const bashIdx = frame.indexOf('Bash(echo hi)');
106
+ const globIdx = frame.indexOf('Glob(**/*.md)');
107
+ expect(readIdx).toBeLessThan(bashIdx);
108
+ expect(bashIdx).toBeLessThan(globIdx);
109
+ });
110
+
111
+ it('renders tool diffs through markdown diff body format', () => {
112
+ const { lastFrame } = render(
113
+ <StreamingIndicator
114
+ text=""
115
+ activeTools={[
116
+ {
117
+ toolName: 'Edit',
118
+ firstArg: '/src/index.ts',
119
+ isRunning: false,
120
+ result: 'success',
121
+ diffFile: '/src/index.ts',
122
+ diffLines: [
123
+ { type: 'remove', lineNumber: 1, text: 'const oldValue = true;' },
124
+ { type: 'add', lineNumber: 1, text: 'const newValue = true;' },
125
+ ],
126
+ },
127
+ ]}
128
+ />,
129
+ );
130
+
131
+ const frame = lastFrame() ?? '';
132
+ expect(frame).toContain('/src/index.ts');
133
+ expect(frame).toContain('- 1 | const oldValue = true;');
134
+ expect(frame).toContain('+ 1 | const newValue = true;');
135
+ expect(frame).not.toContain('│ 1 - const oldValue = true;');
136
+ });
137
+ });