@dexto/tui 1.13.5 → 1.14.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 (72) hide show
  1. package/dist/agent-backend.cjs +2 -0
  2. package/dist/agent-backend.d.ts +2 -0
  3. package/dist/agent-backend.d.ts.map +1 -1
  4. package/dist/agent-backend.js +2 -0
  5. package/dist/components/ApprovalPrompt.cjs +19 -17
  6. package/dist/components/ApprovalPrompt.d.ts +2 -1
  7. package/dist/components/ApprovalPrompt.d.ts.map +1 -1
  8. package/dist/components/ApprovalPrompt.js +19 -17
  9. package/dist/components/ApprovalPrompt.test.cjs +119 -0
  10. package/dist/components/ApprovalPrompt.test.d.ts +2 -0
  11. package/dist/components/ApprovalPrompt.test.d.ts.map +1 -0
  12. package/dist/components/ApprovalPrompt.test.js +120 -0
  13. package/dist/components/Footer.cjs +5 -1
  14. package/dist/components/Footer.d.ts.map +1 -1
  15. package/dist/components/Footer.js +5 -1
  16. package/dist/components/Footer.test.cjs +47 -0
  17. package/dist/components/Footer.test.d.ts +2 -0
  18. package/dist/components/Footer.test.d.ts.map +1 -0
  19. package/dist/components/Footer.test.js +46 -0
  20. package/dist/components/modes/AlternateBufferCLI.cjs +3 -1
  21. package/dist/components/modes/AlternateBufferCLI.d.ts.map +1 -1
  22. package/dist/components/modes/AlternateBufferCLI.js +3 -1
  23. package/dist/components/modes/StaticCLI.cjs +3 -1
  24. package/dist/components/modes/StaticCLI.d.ts.map +1 -1
  25. package/dist/components/modes/StaticCLI.js +3 -1
  26. package/dist/components/overlays/PermissionsSelector.cjs +139 -0
  27. package/dist/components/overlays/PermissionsSelector.d.ts +16 -0
  28. package/dist/components/overlays/PermissionsSelector.d.ts.map +1 -0
  29. package/dist/components/overlays/PermissionsSelector.js +115 -0
  30. package/dist/components/overlays/PermissionsSelector.test.cjs +105 -0
  31. package/dist/components/overlays/PermissionsSelector.test.d.ts +2 -0
  32. package/dist/components/overlays/PermissionsSelector.test.d.ts.map +1 -0
  33. package/dist/components/overlays/PermissionsSelector.test.js +104 -0
  34. package/dist/containers/InputContainer.cjs +108 -28
  35. package/dist/containers/InputContainer.d.ts +1 -0
  36. package/dist/containers/InputContainer.d.ts.map +1 -1
  37. package/dist/containers/InputContainer.js +108 -28
  38. package/dist/containers/OverlayContainer.cjs +15 -0
  39. package/dist/containers/OverlayContainer.d.ts.map +1 -1
  40. package/dist/containers/OverlayContainer.js +17 -0
  41. package/dist/hooks/useAgentEvents.cjs +6 -3
  42. package/dist/hooks/useAgentEvents.d.ts.map +1 -1
  43. package/dist/hooks/useAgentEvents.js +6 -3
  44. package/dist/hooks/useCLIState.cjs +28 -8
  45. package/dist/hooks/useCLIState.d.ts +1 -0
  46. package/dist/hooks/useCLIState.d.ts.map +1 -1
  47. package/dist/hooks/useCLIState.js +28 -8
  48. package/dist/hooks/useCLIState.test.cjs +583 -0
  49. package/dist/hooks/useCLIState.test.d.ts +2 -0
  50. package/dist/hooks/useCLIState.test.d.ts.map +1 -0
  51. package/dist/hooks/useCLIState.test.js +582 -0
  52. package/dist/index.d.cts +2 -0
  53. package/dist/interactive-commands/commands.test.cjs +6 -0
  54. package/dist/interactive-commands/commands.test.js +6 -0
  55. package/dist/interactive-commands/tool-commands.cjs +7 -0
  56. package/dist/interactive-commands/tool-commands.d.ts.map +1 -1
  57. package/dist/interactive-commands/tool-commands.js +7 -0
  58. package/dist/services/processStream.cjs +4 -2
  59. package/dist/services/processStream.d.ts.map +1 -1
  60. package/dist/services/processStream.js +4 -2
  61. package/dist/services/processStream.test.cjs +63 -0
  62. package/dist/services/processStream.test.js +63 -0
  63. package/dist/state/types.d.ts +1 -1
  64. package/dist/state/types.d.ts.map +1 -1
  65. package/dist/utils/approvalScope.cjs +35 -0
  66. package/dist/utils/approvalScope.d.ts +3 -0
  67. package/dist/utils/approvalScope.d.ts.map +1 -0
  68. package/dist/utils/approvalScope.js +11 -0
  69. package/dist/utils/commandOverlays.cjs +1 -0
  70. package/dist/utils/commandOverlays.d.ts.map +1 -1
  71. package/dist/utils/commandOverlays.js +1 -0
  72. package/package.json +5 -5
@@ -0,0 +1,582 @@
1
+ import { createElement, createRef, Fragment } from "react";
2
+ import { PassThrough } from "node:stream";
3
+ import { render, Static, Text } from "ink";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { useCLIState } from "./useCLIState.js";
6
+ import { KeypressProvider } from "../contexts/KeypressContext.js";
7
+ import { AgentEventBus } from "@dexto/core";
8
+ import { createUserMessage } from "../utils/messageFormatting.js";
9
+ import { InputContainer } from "../containers/InputContainer.js";
10
+ vi.mock("../components/input/InputArea.js", () => ({ InputArea: () => null }));
11
+ const agent = {
12
+ getCurrentLLMConfig: () => ({ provider: "openai", model: "gpt-5" }),
13
+ getSessionHistory: async () => [],
14
+ on: () => {
15
+ }
16
+ };
17
+ function mountState(initialSessionId, backend = agent, initialPrompt, withInput = false) {
18
+ const inputRef = createRef();
19
+ const stdout = Object.assign(new PassThrough(), { columns: 120, rows: 40, isTTY: false });
20
+ let state;
21
+ let output = "";
22
+ stdout.on("data", (chunk) => {
23
+ output += chunk.toString();
24
+ });
25
+ function Probe() {
26
+ state = useCLIState({
27
+ agent: backend,
28
+ initialSessionId,
29
+ initialBypassPermissions: true,
30
+ startupInfo: {
31
+ connectedServers: { count: 0, names: [] },
32
+ failedConnections: [],
33
+ toolCount: 0,
34
+ logFile: null
35
+ }
36
+ });
37
+ const transcript = createElement(Static, {
38
+ items: state.visibleMessages,
39
+ children: (message) => createElement(Text, { key: message.id }, message.content)
40
+ });
41
+ return createElement(
42
+ Fragment,
43
+ null,
44
+ transcript,
45
+ withInput ? createElement(InputContainer, {
46
+ ...state,
47
+ ref: inputRef,
48
+ initialPrompt,
49
+ configFilePath: null
50
+ }) : null
51
+ );
52
+ }
53
+ const view = render(createElement(KeypressProvider, null, createElement(Probe)), {
54
+ stdout,
55
+ patchConsole: false,
56
+ exitOnCtrlC: false
57
+ });
58
+ return {
59
+ getState() {
60
+ if (!state) throw new Error("Not mounted");
61
+ return state;
62
+ },
63
+ submit(text) {
64
+ if (!inputRef.current) throw new Error("Input not mounted");
65
+ return inputRef.current.submit(text);
66
+ },
67
+ output() {
68
+ return output;
69
+ },
70
+ close() {
71
+ view.unmount();
72
+ view.cleanup();
73
+ }
74
+ };
75
+ }
76
+ describe("TUI session permission modes", () => {
77
+ it("keeps startup auto-approval when the first session is created, then resets on switch", async () => {
78
+ const screen = mountState(null);
79
+ try {
80
+ await vi.waitFor(() => expect(screen.getState().ui.bypassPermissions).toBe(true));
81
+ screen.getState().setSession((previous) => ({ ...previous, id: "a", hasActiveSession: true }));
82
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
83
+ expect(screen.getState().ui.bypassPermissions).toBe(true);
84
+ screen.getState().setSession((previous) => ({ ...previous, id: "b", hasActiveSession: true }));
85
+ await vi.waitFor(() => expect(screen.getState().ui.bypassPermissions).toBe(false));
86
+ } finally {
87
+ screen.close();
88
+ }
89
+ });
90
+ it("resets accept-edits mode when a conversation is cleared", async () => {
91
+ const screen = mountState("a");
92
+ try {
93
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
94
+ screen.getState().setUi((previous) => ({
95
+ ...previous,
96
+ autoApproveEdits: true,
97
+ bypassPermissions: false
98
+ }));
99
+ await vi.waitFor(() => expect(screen.getState().ui.autoApproveEdits).toBe(true));
100
+ screen.getState().setSession((previous) => ({ ...previous, id: null, hasActiveSession: false }));
101
+ await vi.waitFor(() => expect(screen.getState().ui.autoApproveEdits).toBe(false));
102
+ } finally {
103
+ screen.close();
104
+ }
105
+ });
106
+ });
107
+ describe("TUI resumed history", () => {
108
+ it("waits for the actual history snapshot before persisting the startup prompt", async () => {
109
+ let releaseHistory = () => {
110
+ };
111
+ const ready = new Promise((resolve) => {
112
+ releaseHistory = resolve;
113
+ });
114
+ const persisted = [
115
+ { role: "user", content: [{ type: "text", text: "earlier task" }] }
116
+ ];
117
+ const stream = vi.fn(async function* (content) {
118
+ persisted.push({ role: "user", content: [{ type: "text", text: content }] });
119
+ yield { name: "run:complete", sessionId: "a", durationMs: 0, totalTokens: 0 };
120
+ });
121
+ const backend = {
122
+ ...agent,
123
+ stream,
124
+ getSessionMetadata: async () => ({ messageCount: 1 }),
125
+ logger: { error: vi.fn() },
126
+ getSessionHistory: vi.fn(async () => {
127
+ await ready;
128
+ return [...persisted];
129
+ })
130
+ };
131
+ const screen = mountState("a", backend, "startup task", true);
132
+ try {
133
+ await vi.waitFor(() => expect(backend.getSessionHistory).toHaveBeenCalled());
134
+ expect(stream).not.toHaveBeenCalled();
135
+ screen.getState().buffer.setText("new draft");
136
+ await vi.waitFor(() => expect(screen.getState().input.value).toBe("new draft"));
137
+ releaseHistory();
138
+ await vi.waitFor(() => expect(stream).toHaveBeenCalledTimes(1));
139
+ expect(screen.getState().messages.map((message) => message.content)).toEqual([
140
+ "earlier task",
141
+ "startup task"
142
+ ]);
143
+ expect(screen.getState().input.value).toBe("new draft");
144
+ expect(screen.output().match(/startup task/g)).toHaveLength(1);
145
+ } finally {
146
+ releaseHistory();
147
+ screen.close();
148
+ }
149
+ });
150
+ it("cancels the startup prompt after switching away from its pending resumed session", async () => {
151
+ let releaseHistory = () => {
152
+ };
153
+ const ready = new Promise((resolve) => {
154
+ releaseHistory = resolve;
155
+ });
156
+ const stream = vi.fn(async function* (_content) {
157
+ yield { name: "run:complete", sessionId: "b", durationMs: 0, totalTokens: 0 };
158
+ });
159
+ const backend = {
160
+ ...agent,
161
+ stream,
162
+ getSessionMetadata: async () => ({ messageCount: 1 }),
163
+ logger: { error: vi.fn() },
164
+ getSessionHistory: vi.fn(async () => {
165
+ await ready;
166
+ return [];
167
+ })
168
+ };
169
+ const screen = mountState("a", backend, "startup task for a", true);
170
+ try {
171
+ await vi.waitFor(() => expect(backend.getSessionHistory).toHaveBeenCalledWith("a"));
172
+ screen.getState().setSession((previous) => ({ ...previous, id: "b" }));
173
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("b"));
174
+ expect(stream).not.toHaveBeenCalled();
175
+ await screen.submit("manual task for b");
176
+ expect(stream).toHaveBeenCalledExactlyOnceWith("manual task for b", "b");
177
+ releaseHistory();
178
+ screen.getState().setUi((previous) => ({ ...previous, isThinking: true }));
179
+ await vi.waitFor(() => expect(screen.getState().ui.isThinking).toBe(true));
180
+ expect(stream).toHaveBeenCalledTimes(1);
181
+ expect(screen.getState().messages.map((message) => message.content)).toEqual([
182
+ "manual task for b"
183
+ ]);
184
+ } finally {
185
+ releaseHistory();
186
+ screen.close();
187
+ }
188
+ });
189
+ it("still creates a first session for an ordinary startup prompt", async () => {
190
+ const createSession = vi.fn(async () => ({ id: "created" }));
191
+ const stream = vi.fn(async function* (_content) {
192
+ yield { name: "run:complete", sessionId: "created", durationMs: 0, totalTokens: 0 };
193
+ });
194
+ const backend = {
195
+ ...agent,
196
+ createSession,
197
+ stream,
198
+ getSessionMetadata: async () => ({ messageCount: 1 }),
199
+ logger: { error: vi.fn() }
200
+ };
201
+ const screen = mountState(null, backend, "new startup task", true);
202
+ try {
203
+ await vi.waitFor(
204
+ () => expect(stream).toHaveBeenCalledExactlyOnceWith("new startup task", "created")
205
+ );
206
+ expect(createSession).toHaveBeenCalledTimes(1);
207
+ expect(screen.getState().session.id).toBe("created");
208
+ } finally {
209
+ screen.close();
210
+ }
211
+ });
212
+ it("preserves multiple submissions and a newer draft while resumed history is pending", async () => {
213
+ let releaseHistory = () => {
214
+ };
215
+ const ready = new Promise((resolve) => {
216
+ releaseHistory = resolve;
217
+ });
218
+ const stream = vi.fn(async function* (_content) {
219
+ yield { name: "run:complete", sessionId: "a", durationMs: 0, totalTokens: 0 };
220
+ });
221
+ const backend = {
222
+ ...agent,
223
+ stream,
224
+ getSessionMetadata: async () => ({ messageCount: 1 }),
225
+ logger: { error: vi.fn() },
226
+ getSessionHistory: vi.fn(async () => {
227
+ await ready;
228
+ return [{ role: "user", content: [{ type: "text", text: "earlier task" }] }];
229
+ })
230
+ };
231
+ const screen = mountState("a", backend, "startup task", true);
232
+ try {
233
+ await vi.waitFor(() => expect(backend.getSessionHistory).toHaveBeenCalled());
234
+ const first = screen.submit("first task");
235
+ const second = screen.submit("second task");
236
+ const repeated = screen.submit("first task");
237
+ screen.getState().buffer.setText("newer draft");
238
+ await vi.waitFor(() => expect(screen.getState().input.value).toBe("newer draft"));
239
+ expect(stream).not.toHaveBeenCalled();
240
+ releaseHistory();
241
+ await Promise.all([first, second, repeated]);
242
+ await vi.waitFor(() => expect(stream).toHaveBeenCalledTimes(4));
243
+ expect(stream.mock.calls.map(([content]) => content)).toEqual([
244
+ "startup task",
245
+ "first task",
246
+ "second task",
247
+ "first task"
248
+ ]);
249
+ expect(screen.getState().input.history).toEqual([
250
+ "earlier task",
251
+ "startup task",
252
+ "first task",
253
+ "second task",
254
+ "first task"
255
+ ]);
256
+ expect(screen.getState().input.value).toBe("newer draft");
257
+ } finally {
258
+ releaseHistory();
259
+ screen.close();
260
+ }
261
+ });
262
+ it("releases deferred submission after history failure without hydrating again on return", async () => {
263
+ let releaseHistory = () => {
264
+ };
265
+ const ready = new Promise((resolve) => {
266
+ releaseHistory = resolve;
267
+ });
268
+ const stream = vi.fn(async function* (_content) {
269
+ yield { name: "run:complete", sessionId: "a", durationMs: 0, totalTokens: 0 };
270
+ });
271
+ const backend = {
272
+ ...agent,
273
+ stream,
274
+ getSessionMetadata: async () => ({ messageCount: 1 }),
275
+ logger: { error: vi.fn() },
276
+ getSessionHistory: vi.fn(async () => {
277
+ await ready;
278
+ throw new Error("history unavailable");
279
+ })
280
+ };
281
+ const screen = mountState("a", backend, void 0, true);
282
+ try {
283
+ await vi.waitFor(() => expect(backend.getSessionHistory).toHaveBeenCalled());
284
+ const pending = screen.submit("continue anyway");
285
+ expect(stream).not.toHaveBeenCalled();
286
+ releaseHistory();
287
+ await pending;
288
+ await vi.waitFor(() => expect(stream).toHaveBeenCalledTimes(1));
289
+ expect(screen.getState().messages.map((message) => message.content)).toEqual([
290
+ "Error: history unavailable",
291
+ "continue anyway"
292
+ ]);
293
+ screen.getState().setSession((previous) => ({ ...previous, id: "b" }));
294
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("b"));
295
+ screen.getState().setSession((previous) => ({ ...previous, id: "a" }));
296
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
297
+ expect(backend.getSessionHistory).toHaveBeenCalledTimes(1);
298
+ } finally {
299
+ releaseHistory();
300
+ screen.close();
301
+ }
302
+ });
303
+ it("does not send deferred submissions into a different session", async () => {
304
+ let releaseHistory = () => {
305
+ };
306
+ const ready = new Promise((resolve) => {
307
+ releaseHistory = resolve;
308
+ });
309
+ const stream = vi.fn();
310
+ const backend = {
311
+ ...agent,
312
+ stream,
313
+ getSessionHistory: vi.fn(async () => {
314
+ await ready;
315
+ return [];
316
+ })
317
+ };
318
+ const screen = mountState("a", backend, void 0, true);
319
+ try {
320
+ await vi.waitFor(() => expect(backend.getSessionHistory).toHaveBeenCalled());
321
+ const pending = screen.submit("session a task");
322
+ screen.getState().setSession((previous) => ({ ...previous, id: "b" }));
323
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("b"));
324
+ await pending;
325
+ releaseHistory();
326
+ expect(stream).not.toHaveBeenCalled();
327
+ expect(screen.getState().messages).toEqual([]);
328
+ } finally {
329
+ releaseHistory();
330
+ screen.close();
331
+ }
332
+ });
333
+ it("preserves resumed history and a prompt submitted while history is loading", async () => {
334
+ let finishHistory = () => {
335
+ throw new Error("History request not started");
336
+ };
337
+ const history = new Promise((resolve) => {
338
+ finishHistory = resolve;
339
+ });
340
+ const getSessionHistory = vi.fn(() => history);
341
+ const screen = mountState("a", { ...agent, getSessionHistory });
342
+ try {
343
+ await vi.waitFor(() => expect(getSessionHistory).toHaveBeenCalledWith("a"));
344
+ const prompt = createUserMessage("continue this task");
345
+ screen.getState().setMessages((previous) => [...previous, prompt]);
346
+ screen.getState().setInput((previous) => ({
347
+ ...previous,
348
+ history: [...previous.history, prompt.content]
349
+ }));
350
+ await vi.waitFor(() => expect(screen.getState().messages).toEqual([prompt]));
351
+ expect(screen.output()).not.toContain("continue this task");
352
+ finishHistory([
353
+ { role: "user", content: [{ type: "text", text: "earlier task" }] },
354
+ {
355
+ role: "assistant",
356
+ assistantOutput: { status: "complete" },
357
+ content: [{ type: "text", text: "earlier answer" }]
358
+ }
359
+ ]);
360
+ await vi.waitFor(
361
+ () => expect(screen.getState().messages.map((message) => message.content)).toEqual([
362
+ "earlier task",
363
+ "earlier answer",
364
+ "continue this task"
365
+ ])
366
+ );
367
+ expect(screen.getState().input.history).toEqual(["earlier task", "continue this task"]);
368
+ await vi.waitFor(() => expect(screen.output()).toContain("continue this task"));
369
+ expect(screen.output().indexOf("earlier task")).toBeLessThan(
370
+ screen.output().indexOf("continue this task")
371
+ );
372
+ expect(screen.output().indexOf("earlier answer")).toBeLessThan(
373
+ screen.output().indexOf("continue this task")
374
+ );
375
+ expect(screen.output().match(/continue this task/g)).toHaveLength(1);
376
+ expect(getSessionHistory).toHaveBeenCalledTimes(1);
377
+ } finally {
378
+ screen.close();
379
+ }
380
+ });
381
+ it("discards pending resumed history after switching to another session", async () => {
382
+ let finishHistory = () => {
383
+ throw new Error("History request not started");
384
+ };
385
+ const history = new Promise((resolve) => {
386
+ finishHistory = resolve;
387
+ });
388
+ const getSessionHistory = vi.fn(() => history);
389
+ const screen = mountState("a", { ...agent, getSessionHistory });
390
+ try {
391
+ await vi.waitFor(() => expect(getSessionHistory).toHaveBeenCalledWith("a"));
392
+ screen.getState().setSession((previous) => ({
393
+ ...previous,
394
+ id: "b",
395
+ hasActiveSession: true
396
+ }));
397
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("b"));
398
+ const prompt = createUserMessage("session b prompt");
399
+ screen.getState().setMessages([prompt]);
400
+ screen.getState().setInput((previous) => ({ ...previous, history: [prompt.content] }));
401
+ await vi.waitFor(() => expect(screen.output()).toContain("session b prompt"));
402
+ finishHistory([
403
+ { role: "user", content: [{ type: "text", text: "session a history" }] }
404
+ ]);
405
+ await history;
406
+ screen.getState().setUi((previous) => ({ ...previous, isThinking: true }));
407
+ await vi.waitFor(() => expect(screen.getState().ui.isThinking).toBe(true));
408
+ expect(screen.getState().messages).toEqual([prompt]);
409
+ expect(screen.getState().input.history).toEqual(["session b prompt"]);
410
+ expect(screen.output()).not.toContain("session a history");
411
+ expect(getSessionHistory).toHaveBeenCalledTimes(1);
412
+ } finally {
413
+ screen.close();
414
+ }
415
+ });
416
+ it("loads ordinary resumed history once without replacing a current input draft", async () => {
417
+ const getSessionHistory = vi.fn(
418
+ async () => [
419
+ { role: "user", content: [{ type: "text", text: "earlier task" }] },
420
+ {
421
+ role: "assistant",
422
+ assistantOutput: { status: "complete" },
423
+ content: [{ type: "text", text: "earlier answer" }]
424
+ }
425
+ ]
426
+ );
427
+ const screen = mountState("a", { ...agent, getSessionHistory });
428
+ try {
429
+ await vi.waitFor(() => expect(screen.getState().messages).toHaveLength(2));
430
+ screen.getState().buffer.setText("next task draft");
431
+ await vi.waitFor(() => expect(screen.getState().input.value).toBe("next task draft"));
432
+ expect(screen.getState().input.history).toEqual(["earlier task"]);
433
+ screen.getState().setSession((previous) => ({ ...previous, id: "b" }));
434
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("b"));
435
+ screen.getState().setSession((previous) => ({ ...previous, id: "a" }));
436
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
437
+ expect(screen.getState().messages.map((message) => message.content)).toEqual([
438
+ "earlier task",
439
+ "earlier answer"
440
+ ]);
441
+ expect(getSessionHistory).toHaveBeenCalledTimes(1);
442
+ } finally {
443
+ screen.close();
444
+ }
445
+ });
446
+ it("releases submitted messages to static output when resumed history fails", async () => {
447
+ let releaseHistory = () => {
448
+ };
449
+ const ready = new Promise((resolve) => {
450
+ releaseHistory = resolve;
451
+ });
452
+ const getSessionHistory = vi.fn(async () => {
453
+ await ready;
454
+ throw new Error("history unavailable");
455
+ });
456
+ const screen = mountState("a", { ...agent, getSessionHistory });
457
+ try {
458
+ await vi.waitFor(() => expect(getSessionHistory).toHaveBeenCalledWith("a"));
459
+ const prompt = createUserMessage("continue without history");
460
+ screen.getState().setMessages([prompt]);
461
+ await vi.waitFor(() => expect(screen.getState().messages).toEqual([prompt]));
462
+ expect(screen.output()).not.toContain(prompt.content);
463
+ releaseHistory();
464
+ await vi.waitFor(() => expect(screen.output()).toContain("Error: history unavailable"));
465
+ expect(screen.output()).toContain(prompt.content);
466
+ expect(screen.output().match(/continue without history/g)).toHaveLength(1);
467
+ expect(screen.getState().messages.map((message) => message.content)).toEqual([
468
+ prompt.content,
469
+ "Error: history unavailable"
470
+ ]);
471
+ } finally {
472
+ screen.close();
473
+ }
474
+ });
475
+ });
476
+ describe("TUI session queue isolation", () => {
477
+ it("keeps a current-session queue snapshot while the composer changes", async () => {
478
+ const events = new AgentEventBus();
479
+ let release = () => {
480
+ };
481
+ const pending = new Promise((resolve) => {
482
+ release = resolve;
483
+ });
484
+ const messages = [
485
+ { id: "queued", content: [{ type: "text", text: "queued task" }], queuedAt: 1 }
486
+ ];
487
+ const backend = {
488
+ ...agent,
489
+ on: events.on.bind(events),
490
+ getSteerMessages: vi.fn(async () => pending)
491
+ };
492
+ const screen = mountState("a", backend);
493
+ try {
494
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
495
+ events.emit("message:queued", {
496
+ sessionId: "a",
497
+ queue: "steer",
498
+ id: "queued",
499
+ position: 1
500
+ });
501
+ screen.getState().buffer.setText("a newer draft");
502
+ await vi.waitFor(() => expect(screen.getState().input.value).toBe("a newer draft"));
503
+ release(messages);
504
+ await vi.waitFor(() => expect(screen.getState().steerMessages).toEqual(messages));
505
+ expect(screen.getState().input.value).toBe("a newer draft");
506
+ } finally {
507
+ release([]);
508
+ screen.close();
509
+ }
510
+ });
511
+ it.each(["steer", "follow-up"])(
512
+ "ignores %s queue events from another session",
513
+ async (queue) => {
514
+ const events = new AgentEventBus();
515
+ const getMessages = vi.fn(async () => []);
516
+ const backend = {
517
+ ...agent,
518
+ on: events.on.bind(events),
519
+ getSteerMessages: getMessages,
520
+ getFollowUpMessages: getMessages
521
+ };
522
+ const screen = mountState("a", backend);
523
+ try {
524
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
525
+ events.emit("message:queued", { sessionId: "b", queue, id: "other", position: 1 });
526
+ events.emit("message:removed", { sessionId: "b", queue, id: "other" });
527
+ expect(getMessages).not.toHaveBeenCalled();
528
+ } finally {
529
+ screen.close();
530
+ }
531
+ }
532
+ );
533
+ it.each(["steer", "follow-up"])(
534
+ "discards a pending %s snapshot after switching sessions",
535
+ async (queue) => {
536
+ const events = new AgentEventBus();
537
+ let release = () => {
538
+ };
539
+ const pending = new Promise((resolve) => {
540
+ release = resolve;
541
+ });
542
+ const current = [
543
+ { id: "current", content: [{ type: "text", text: "current task" }], queuedAt: 1 }
544
+ ];
545
+ const getMessages = vi.fn(
546
+ async (sessionId) => sessionId === "a" ? pending : current
547
+ );
548
+ const backend = {
549
+ ...agent,
550
+ on: events.on.bind(events),
551
+ getSteerMessages: getMessages,
552
+ getFollowUpMessages: getMessages
553
+ };
554
+ const screen = mountState("a", backend);
555
+ const visibleQueue = () => queue === "steer" ? screen.getState().steerMessages : screen.getState().queuedMessages;
556
+ try {
557
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
558
+ events.emit("message:queued", { sessionId: "a", queue, id: "old", position: 1 });
559
+ expect(getMessages).toHaveBeenCalledWith("a");
560
+ screen.getState().setSession((previous) => ({ ...previous, id: "b" }));
561
+ await vi.waitFor(() => expect(screen.getState().session.id).toBe("b"));
562
+ events.emit("message:queued", {
563
+ sessionId: "b",
564
+ queue,
565
+ id: "current",
566
+ position: 1
567
+ });
568
+ await vi.waitFor(() => expect(visibleQueue()).toEqual(current));
569
+ release([
570
+ { id: "old", content: [{ type: "text", text: "old task" }], queuedAt: 0 }
571
+ ]);
572
+ await pending;
573
+ screen.getState().setUi((previous) => ({ ...previous, isThinking: true }));
574
+ await vi.waitFor(() => expect(screen.getState().ui.isThinking).toBe(true));
575
+ expect(visibleQueue()).toEqual(current);
576
+ } finally {
577
+ release([]);
578
+ screen.close();
579
+ }
580
+ }
581
+ );
582
+ });
package/dist/index.d.cts CHANGED
@@ -229,6 +229,8 @@ interface TuiEffectiveConfig {
229
229
  [key: string]: unknown;
230
230
  }
231
231
  interface TuiAgentBackend extends Pick<DextoAgent, 'stream' | 'stop' | 'run' | 'listSessions' | 'getSessionMetadata' | 'getSessionHistory' | 'getSessionTitle' | 'setSessionTitle' | 'generateSessionTitle' | 'forkSession' | 'getCurrentLLMConfig' | 'hasSessionLLMOverride' | 'switchLLM' | 'getSupportedProviders' | 'getSupportedModels' | 'getContextStats' | 'clearContext' | 'compactContext' | 'steer' | 'followUp' | 'getSteerMessages' | 'getFollowUpMessages' | 'removeSteerMessage' | 'removeFollowUpMessage' | 'clearSteerQueue' | 'clearFollowUpQueue' | 'cancel' | 'searchMessages' | 'listPrompts' | 'refreshPrompts' | 'resolvePrompt' | 'getSystemPrompt' | 'loadToolkits' | 'listResources' | 'setLogLevel' | 'getAllTools' | 'getEnabledTools' | 'getAllMcpTools' | 'setGlobalDisabledTools' | 'setSessionDisabledTools' | 'setSessionAutoApproveTools' | 'getSessionAutoApproveTools' | 'getMcpServersWithStatus' | 'addMcpServer' | 'enableMcpServer' | 'disableMcpServer' | 'removeMcpServer' | 'restartMcpServer' | 'getMcpClients' | 'getMcpFailedConnections'> {
232
+ getSessionApprovals?: DextoAgent['getSessionApprovals'];
233
+ revokeSessionApproval?: DextoAgent['revokeSessionApproval'];
232
234
  createSession: (sessionId?: string) => Promise<{
233
235
  id: string;
234
236
  logger: Pick<RootLogger, 'getLevel' | 'getLogFilePath'>;
@@ -22,6 +22,12 @@ function createAgent(capabilities) {
22
22
  };
23
23
  }
24
24
  (0, import_vitest.describe)("executeCommand", () => {
25
+ (0, import_vitest.it)("does not advertise local permission controls for an unsupported backend", async () => {
26
+ const agent = createAgent();
27
+ (0, import_vitest.expect)(await (0, import_commands.executeCommand)("permissions", [], agent)).toBe(
28
+ "\u26A0\uFE0F Command /permissions is not available for this chat target."
29
+ );
30
+ });
25
31
  (0, import_vitest.it)("returns unknown-command guidance when prompts are unsupported and the command is not real", async () => {
26
32
  const agent = createAgent({ prompts: false });
27
33
  const result = await (0, import_commands.executeCommand)("typoed-command", [], agent);
@@ -21,6 +21,12 @@ function createAgent(capabilities) {
21
21
  };
22
22
  }
23
23
  describe("executeCommand", () => {
24
+ it("does not advertise local permission controls for an unsupported backend", async () => {
25
+ const agent = createAgent();
26
+ expect(await executeCommand("permissions", [], agent)).toBe(
27
+ "\u26A0\uFE0F Command /permissions is not available for this chat target."
28
+ );
29
+ });
24
30
  it("returns unknown-command guidance when prompts are unsupported and the command is not real", async () => {
25
31
  const agent = createAgent({ prompts: false });
26
32
  const result = await executeCommand("typoed-command", [], agent);
@@ -22,6 +22,13 @@ __export(tool_commands_exports, {
22
22
  });
23
23
  module.exports = __toCommonJS(tool_commands_exports);
24
24
  const toolCommands = [
25
+ {
26
+ name: "permissions",
27
+ description: "Choose approval mode and revoke remembered session permissions",
28
+ usage: "/permissions",
29
+ category: "Tool Management",
30
+ handler: async () => true
31
+ },
25
32
  {
26
33
  name: "tools",
27
34
  description: "Browse available tools interactively",
@@ -1 +1 @@
1
- {"version":3,"file":"tool-commands.d.ts","sourceRoot":"","sources":["../../src/interactive-commands/tool-commands.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAkB,MAAM,qBAAqB,CAAC;AAG7E;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,iBAAiB,EAe3C,CAAC"}
1
+ {"version":3,"file":"tool-commands.d.ts","sourceRoot":"","sources":["../../src/interactive-commands/tool-commands.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAkB,MAAM,qBAAqB,CAAC;AAG7E;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,iBAAiB,EAsB3C,CAAC"}
@@ -1,4 +1,11 @@
1
1
  const toolCommands = [
2
+ {
3
+ name: "permissions",
4
+ description: "Choose approval mode and revoke remembered session permissions",
5
+ usage: "/permissions",
6
+ category: "Tool Management",
7
+ handler: async () => true
8
+ },
2
9
  {
3
10
  name: "tools",
4
11
  description: "Browse available tools interactively",
@@ -766,7 +766,7 @@ ${import_chalk.default.dim(runningDescription)}`;
766
766
  const bypassPermissions = options.bypassPermissionsRef.current;
767
767
  const autoApproveEdits = options.autoApproveEditsRef.current;
768
768
  const { eventBus } = options;
769
- if (bypassPermissions && (event.type === import_core.ApprovalType.TOOL_APPROVAL || event.type === import_core.ApprovalType.COMMAND_APPROVAL)) {
769
+ if (bypassPermissions && event.autoApproval !== "disallowed" && (event.type === import_core.ApprovalType.TOOL_APPROVAL || event.type === import_core.ApprovalType.COMMAND_APPROVAL)) {
770
770
  if (event.type === import_core.ApprovalType.TOOL_APPROVAL) {
771
771
  const { toolName } = event.metadata;
772
772
  if (toolName === "plan_create" || toolName === "plan_review") {
@@ -786,7 +786,7 @@ ${import_chalk.default.dim(runningDescription)}`;
786
786
  });
787
787
  break;
788
788
  }
789
- if (autoApproveEdits && event.type === import_core.ApprovalType.TOOL_APPROVAL) {
789
+ if (autoApproveEdits && event.autoApproval !== "disallowed" && event.type === import_core.ApprovalType.TOOL_APPROVAL) {
790
790
  const { toolName, approvalKey } = event.metadata;
791
791
  if (approvalKey === void 0 && (0, import_toolUtils.isAutoApprovableInEditMode)(toolName)) {
792
792
  eventBus.emit("approval:response", {
@@ -811,6 +811,8 @@ ${import_chalk.default.dim(runningDescription)}`;
811
811
  timestamp: event.timestamp,
812
812
  metadata: event.metadata
813
813
  };
814
+ if (event.autoApproval !== void 0)
815
+ newApproval.autoApproval = event.autoApproval;
814
816
  if (event.sessionId !== void 0) {
815
817
  newApproval.sessionId = event.sessionId;
816
818
  }