gaunt-sloth 2.0.0-beta.0 → 2.0.0-beta.2

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 (61) hide show
  1. package/LICENSE +6 -6
  2. package/dist/cli.js +6 -4
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/configSetup.d.ts +13 -1
  5. package/dist/commands/configSetup.js +27 -6
  6. package/dist/commands/configSetup.js.map +1 -1
  7. package/dist/commands/firstRunDialog.d.ts +8 -3
  8. package/dist/commands/firstRunDialog.js +41 -13
  9. package/dist/commands/firstRunDialog.js.map +1 -1
  10. package/dist/commands/initCommand.d.ts +11 -1
  11. package/dist/commands/initCommand.js +21 -4
  12. package/dist/commands/initCommand.js.map +1 -1
  13. package/dist/commands/prDiscovery.js +14 -1
  14. package/dist/commands/prDiscovery.js.map +1 -1
  15. package/dist/modules/startSession.js +4 -1
  16. package/dist/modules/startSession.js.map +1 -1
  17. package/dist/tui/components/App.js +228 -60
  18. package/dist/tui/components/App.js.map +1 -1
  19. package/dist/tui/components/ApprovalPrompt.d.ts +51 -33
  20. package/dist/tui/components/ApprovalPrompt.js +55 -140
  21. package/dist/tui/components/ApprovalPrompt.js.map +1 -1
  22. package/dist/tui/components/ApprovalRequestPanel.d.ts +28 -0
  23. package/dist/tui/components/ApprovalRequestPanel.js +60 -0
  24. package/dist/tui/components/ApprovalRequestPanel.js.map +1 -0
  25. package/dist/tui/components/BlankRow.d.ts +18 -0
  26. package/dist/tui/components/BlankRow.js +22 -0
  27. package/dist/tui/components/BlankRow.js.map +1 -0
  28. package/dist/tui/components/LiveTurn.js +31 -19
  29. package/dist/tui/components/LiveTurn.js.map +1 -1
  30. package/dist/tui/components/NegotiationPanel.d.ts +32 -0
  31. package/dist/tui/components/NegotiationPanel.js +52 -0
  32. package/dist/tui/components/NegotiationPanel.js.map +1 -0
  33. package/dist/tui/components/PromptEditor.d.ts +10 -1
  34. package/dist/tui/components/PromptEditor.js +52 -18
  35. package/dist/tui/components/PromptEditor.js.map +1 -1
  36. package/dist/tui/components/PromptInput.d.ts +46 -1
  37. package/dist/tui/components/PromptInput.js +270 -9
  38. package/dist/tui/components/PromptInput.js.map +1 -1
  39. package/dist/tui/components/SelectList.js +7 -5
  40. package/dist/tui/components/SelectList.js.map +1 -1
  41. package/dist/tui/components/TranscriptViewport.d.ts +2 -1
  42. package/dist/tui/components/TranscriptViewport.js +10 -3
  43. package/dist/tui/components/TranscriptViewport.js.map +1 -1
  44. package/dist/tui/keyBindings.js +7 -0
  45. package/dist/tui/keyBindings.js.map +1 -1
  46. package/dist/tui/keyGuards.d.ts +126 -0
  47. package/dist/tui/keyGuards.js +141 -0
  48. package/dist/tui/keyGuards.js.map +1 -0
  49. package/dist/tui/pasteParser.d.ts +2 -1
  50. package/dist/tui/pasteParser.js +2 -1
  51. package/dist/tui/pasteParser.js.map +1 -1
  52. package/dist/tui/transcriptWindow.d.ts +6 -0
  53. package/dist/tui/transcriptWindow.js +33 -4
  54. package/dist/tui/transcriptWindow.js.map +1 -1
  55. package/dist/tui/tuiSessionModule.js +92 -7
  56. package/dist/tui/tuiSessionModule.js.map +1 -1
  57. package/dist/tui/types.d.ts +51 -5
  58. package/dist/tui/viewModel.d.ts +8 -0
  59. package/dist/tui/viewModel.js +1 -0
  60. package/dist/tui/viewModel.js.map +1 -1
  61. package/package.json +15 -15
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Box, Text, useApp, useInput } from 'ink';
4
4
  import { foldEvents, foldSubagentEvents, initialSubagentTree, initialTurnViewModel, turnReasoning, turnText, } from '#src/tui/viewModel.js';
@@ -9,22 +9,25 @@ import { TranscriptViewport } from '#src/tui/components/TranscriptViewport.js';
9
9
  import { ApprovalPrompt } from '#src/tui/components/ApprovalPrompt.js';
10
10
  import { AttackBanner } from '#src/tui/components/AttackBanner.js';
11
11
  import { LiveTurn, ChecklistPanel } from '#src/tui/components/LiveTurn.js';
12
+ import { NegotiationPanel } from '#src/tui/components/NegotiationPanel.js';
12
13
  import { extractActiveChecklist } from '#src/tui/viewModel.js';
13
14
  import { StatusBar } from '#src/tui/components/StatusBar.js';
14
15
  import { NoticeBar, McpFailureBar } from '#src/tui/components/NoticeBar.js';
15
16
  import { PromptInput } from '#src/tui/components/PromptInput.js';
16
17
  import { Rule } from '#src/tui/components/Rule.js';
18
+ import { BlankRow } from '#src/tui/components/BlankRow.js';
17
19
  import { ClearBanner } from '#src/tui/components/ClearBanner.js';
18
20
  import { LaunchBanner } from '#src/tui/components/LaunchBanner.js';
19
21
  import { MouseProvider } from '#src/tui/useMouse.js';
20
22
  import { DebugPanel, debugPanelLines, DEBUG_TABS, } from '#src/tui/components/DebugPanel.js';
21
- import { approvalPostureChoices, approvalsRungNotice, approvalsStatusNotice, approvalsTrustNotice, createCommandRegistry, dispatchSlashCommand, parseSlashCommand, toolsToggleNotice, } from '@gaunt-sloth/agent/modules/slashCommands.js';
23
+ import { approvalPostureChoices, approvalsRungNotice, approvalsRefusalsNotice, approvalsStatusNotice, approvalsTrustNotice, approvalsUndenyNotice, createCommandRegistry, dispatchSlashCommand, parseSlashCommand, toolsToggleNotice, } from '@gaunt-sloth/agent/modules/slashCommands.js';
22
24
  import { ApprovalsPicker } from '#src/tui/components/ApprovalsPicker.js';
23
25
  import { TerminalSizeProvider, useTerminalSize } from '#src/tui/useTerminalSize.js';
24
26
  import { findMatches, scrollOffsetForLine, stepMatch } from '#src/tui/debugSearch.js';
25
27
  import { useTranscriptScroll } from '#src/tui/useTranscriptScroll.js';
26
28
  import { isComposingKeystroke, WHEEL_ROWS_PER_NOTCH } from '#src/tui/transcriptScroll.js';
27
29
  import { TUI_HINT_SUFFIX, TUI_KEY_BINDINGS } from '#src/tui/keyBindings.js';
30
+ import { isTypedText, typedText } from '#src/tui/keyGuards.js';
28
31
  /** Rows of clipping viewport in the docked debug panel (default / restored size). */
29
32
  const DEBUG_VIEWPORT_HEIGHT = 8;
30
33
  /** Rows of chrome around the debug viewport we leave for when it is maximised (panel
@@ -124,10 +127,17 @@ export function App(props) {
124
127
  // The head is the approval on screen AND the one that owns the keyboard. Deriving both from this
125
128
  // single expression is what keeps those two facts from drifting apart.
126
129
  const pendingApproval = approvalQueue[0] ?? null;
130
+ // [[EXT-137]] — the queue records whose scrollable half has already been committed to the
131
+ // transcript, so a re-render cannot commit a second copy. See the effect that fills it.
132
+ const announcedApprovals = useRef(new Set());
127
133
  // [[TUI-C68]] §6.1 — attack halts, queued exactly as approvals are, and for the same reason: the
128
134
  // head is what is on screen AND what owns the keyboard, from one expression.
129
135
  const [attackQueue, setAttackQueue] = useState([]);
130
136
  const pendingAttack = attackQueue[0] ?? null;
137
+ // [[TUI-C69]] §5.4 — the §5 negotiation's rounds as they happen. A LIST rather than a queue,
138
+ // because nothing is being answered: it is the argument so far, accumulating while the turn is
139
+ // in flight and cleared when the next user turn ends the negotiation.
140
+ const [negotiationRounds, setNegotiationRounds] = useState([]);
131
141
  // What the human has typed into the banner. Held in a ref BESIDE the state because Ink dispatches
132
142
  // every keypress parsed out of one stdin chunk in a synchronous loop: typing `run` can run the
133
143
  // handler three times before a single re-render, and reading the state would give all three the
@@ -157,6 +167,12 @@ export function App(props) {
157
167
  // first rung asks the buffer to scrap itself through this; everything else about the buffer stays
158
168
  // inside <PromptInput>, where it lives.
159
169
  const promptHandleRef = useRef(null);
170
+ // TUI-C51 — the one thing about the prompt that has to outlive it: the message a command was
171
+ // dispatched beside. The prompt is unmounted in four states below, and one of them (`/approvals`
172
+ // opening its picker) is entered by a command dispatched FROM the prompt — so the draft is put
173
+ // here, where nothing unmounts, and the prompt takes it back when it returns. It stays a slot
174
+ // rather than state: nothing here renders it, and the prompt empties it as soon as it has it.
175
+ const promptDraftCarryRef = useRef(null);
160
176
  // TUI-C79 — set the moment a teardown starts, so a second `quit()` can tell "leaving" from
161
177
  // "already leaving" and skip straight to the unmount. See `quit()`.
162
178
  const quittingRef = useRef(false);
@@ -255,6 +271,10 @@ export function App(props) {
255
271
  // the fresh conversation.
256
272
  setClearedBanner(false);
257
273
  push({ kind: 'user', text: userInput });
274
+ // [[TUI-C69]] §5.4 — a new user turn is a person being reached, which ENDS any negotiation
275
+ // still standing (the runner clears its own transcript on the same event). Rounds from the
276
+ // previous turn left on the dock would claim an argument that is over.
277
+ setNegotiationRounds([]);
258
278
  const ac = new AbortController();
259
279
  abortRef.current = ac;
260
280
  runningRef.current = true;
@@ -404,20 +424,49 @@ export function App(props) {
404
424
  });
405
425
  return;
406
426
  }
407
- const { approvals: current, allowlist, deny, grants, trust } = agent.getApprovals();
427
+ const { approvals: current, allowlist, refusals, grants, trust } = agent.getApprovals();
408
428
  approvalsRef.current = current;
409
429
  setApprovals(current);
410
430
  // `interactive` suppresses the notice's TEXT copy of the mode list, because the picker below is
411
431
  // about to render the same four rows. Every non-TTY surface leaves it unset and gets the text.
412
- const { title, lines, tone } = approvalsStatusNotice(current, allowlist, deny, grants, trust, {
413
- interactive: true,
414
- });
432
+ const { title, lines, tone } = approvalsStatusNotice(current, allowlist, refusals, grants, trust, { interactive: true });
415
433
  push({ kind: 'notice', title, lines, tone: tone ?? 'info' });
434
+ // [[EXT-107]] — the refusals as a SECOND notice, after the posture one. Two notices rather than
435
+ // a longer one: this pane is full to the row once the picker below is up, so rows added to the
436
+ // posture notice scroll its own heading — the mode the user just asked about — off the top.
437
+ const refused = approvalsRefusalsNotice(refusals);
438
+ if (refused) {
439
+ push({
440
+ kind: 'notice',
441
+ title: refused.title,
442
+ lines: refused.lines,
443
+ tone: refused.tone ?? 'info',
444
+ });
445
+ }
416
446
  // Only offer to CHANGE the mode when this session can actually apply one.
417
447
  if (agent.setApprovalRung)
418
448
  setApprovalsPicker(approvalPostureChoices(current.rung));
419
449
  // eslint-disable-next-line react-hooks/exhaustive-deps
420
450
  }, [agent]);
451
+ // [[EXT-107]] — `/approvals undeny <n>`: lift one refusal. Same division as the rung and the
452
+ // trust change — the runner owns the lists, applies the removal and reports what LANDED, and the
453
+ // notice is built from that, never from what was asked for. This is the escape hatch from a
454
+ // refusal that is now written to a project file, so it degrades loudly rather than silently when
455
+ // a session has no runner behind it.
456
+ const liftRefusal = useCallback((index) => {
457
+ if (!agent.liftRefusal) {
458
+ push({
459
+ kind: 'system',
460
+ level: 'warning',
461
+ text: 'Approvals are unavailable in this session.',
462
+ });
463
+ return;
464
+ }
465
+ const { title, lines, tone } = approvalsUndenyNotice(agent.liftRefusal(index));
466
+ push({ kind: 'notice', title, lines, tone: tone ?? 'info' });
467
+ },
468
+ // eslint-disable-next-line react-hooks/exhaustive-deps
469
+ [agent]);
421
470
  // EXT-70 §4.7.1 — `/approvals trust|untrust <server> <hint…>`: believe, or stop believing,
422
471
  // specific annotation hints from one MCP server for this session. Same division as the rung: the
423
472
  // runner owns the posture, applies the change and reports what LANDED, and the notice is built
@@ -463,75 +512,141 @@ export function App(props) {
463
512
  // Resolve the currently-shown approval (the queue head) and commit a brief notice so the
464
513
  // decision reads in the transcript (TUI-C14 notice conventions). Dequeues the head so the next
465
514
  // queued approval (if any) surfaces. Approve carries the chosen scope; reject is fail-closed.
515
+ //
516
+ // [[EXT-150]] — **an answer that promises a FILE waits to be told what it got.** The two `always`
517
+ // answers ask for something the runner may not be able to do: [[EXT-149]] degrades an `always`
518
+ // whose write did not reach disk to the session answer it really is, and decides that only after
519
+ // this surface's decision has already been handed back. Written from the keystroke, the
520
+ // confirmation is a claim about a file nobody has tried to write yet — and on a checkout that
521
+ // cannot be written it says *saved to this project* beside core's own ERROR naming the file it
522
+ // could not save to. So `resolve` now answers back, and the two sticky notices are committed from
523
+ // what LANDED (DL-4: confirming something that did not happen is a transparency failure).
524
+ //
525
+ // **One notice, once, when it is known** — never an optimistic line corrected afterwards. A claim
526
+ // that appears and then changes is its own defect, and a user who read the first version has
527
+ // already acted on it.
466
528
  const resolveApproval = (decision) => {
467
529
  const head = pendingApproval;
468
530
  if (!head)
469
531
  return;
532
+ // [[EXT-150]] — **dequeue on the keystroke, ahead of any wait.** The dialog leaving the screen
533
+ // is what makes the answer feel taken, and the next queued approval must surface at once. A
534
+ // dequeue moved below the wait would hold the interface for as long as a write takes, and hold
535
+ // it forever if the outcome never came — trading a false sentence for a wedged prompt.
536
+ setApprovalQueue((q) => q.slice(1));
470
537
  if (decision === 'reject' || decision === 'reject-always') {
471
538
  // [[TUI-C26]] §6 — *always reject* is a rejection that is also REMEMBERED: the runner records
472
539
  // a deny entry for this call, and the matcher consults it before anything else, so the next
473
540
  // identical call never reaches a person. `once` is the ordinary refusal and the fallthrough
474
541
  // every unbound key lands on.
475
542
  const sticky = decision === 'reject-always';
476
- // EXT-58 (§7): hand the model the moves it has (re-call with a justification, a different
477
- // command, or ask the user) plus — when the rater named an already-granted alternative
478
- // (§4.4) — that tool and the clause saying it will not interrupt the user. This is the
479
- // message the MODEL sees; the on-screen notice below is unchanged.
480
- head.resolve({
543
+ // EXT-58 (§7): hand the model the moves it has (re-call with a justification, or a
544
+ // different command) plus — when the rater named an already-granted alternative (§4.4) —
545
+ // that tool and the clause saying it will not interrupt the user. This is the message the
546
+ // MODEL sees, and it is a different thing from the on-screen notice below: one is addressed
547
+ // to the agent and one to the person, and neither is derived from the other.
548
+ const outcome = head.resolve({
481
549
  type: 'reject',
482
- ...(sticky ? { scope: 'session' } : {}),
550
+ // [[EXT-107]] — `always`, which is what the menu label promised. The lifetime that actually
551
+ // lands is core's to decide (a project file that cannot be written degrades to a session
552
+ // refusal), so this surface asks for the scope and never assumes the outcome.
553
+ ...(sticky ? { scope: 'always' } : {}),
483
554
  message: buildRejectionMessage({
484
555
  source: 'user',
485
556
  toolName: head.pending.name,
486
557
  verdict: head.pending.safetyVerdict,
487
558
  }),
488
559
  });
489
- push({
490
- kind: 'notice',
491
- title: sticky ? 'Command refused for this session' : 'Command rejected',
492
- lines: sticky
493
- ? [
494
- // Exactly what it does, and no more. There is no persisted deny store, so a line
495
- // implying one would be §6's "offered and then refused" with the evidence hidden —
496
- // and the refusal itself is real, which is what makes the honest wording affordable.
497
- // The call is deliberately NOT quoted here: a transcript notice is painted raw, and
498
- // the command is the untrusted string this whole dialog frames.
499
- 'This call will be refused for the rest of this session, without asking again.',
500
- 'Nothing was saved to the project, so a new session will ask about it again.',
501
- ]
502
- : ['The shell command was not run; the agent was told you declined.'],
503
- tone: 'warn',
560
+ if (!sticky) {
561
+ // The ordinary refusal records nothing at all, so there is no outcome to wait for and
562
+ // nothing here that core could fail to keep.
563
+ push({
564
+ kind: 'notice',
565
+ title: 'Command rejected',
566
+ lines: ['The shell command was not run; the agent was told you declined.'],
567
+ tone: 'warn',
568
+ });
569
+ return;
570
+ }
571
+ // [[EXT-150]] — the refusal IS in force either way (core holds it in memory whatever the file
572
+ // does), so only its lifetime is in question, and only core can answer that. `null` — the
573
+ // session ended before the answer came — is read as *not saved*, the fail-closed direction
574
+ // for a claim.
575
+ void outcome.then((landed) => {
576
+ const savedToProject = landed?.lifetime === 'always';
577
+ push({
578
+ kind: 'notice',
579
+ // Exactly what it does, and no more — which [[EXT-107]] makes a project file, so it also
580
+ // says how to undo it. A saved refusal the user cannot find is the one failure mode
581
+ // persisting it introduces, and the control that lifts it has to be named where the
582
+ // refusal is made. `/approvals undeny` lists and lifts a session refusal too, so that
583
+ // line is true on both branches and stays on both. The call is deliberately NOT quoted
584
+ // here: a transcript notice is painted raw, and the command is the untrusted string this
585
+ // whole dialog frames.
586
+ title: savedToProject ? 'Command refused and saved' : 'Command refused for this session',
587
+ lines: [
588
+ savedToProject
589
+ ? 'This exact call will be refused from now on, without asking again.'
590
+ : 'This exact call will be refused for the rest of this session, without asking again.',
591
+ savedToProject
592
+ ? 'It is saved to this project, so it stays refused in new sessions.'
593
+ : 'It was not written to the project, so a new session will ask about it again.',
594
+ 'Lift it with /approvals undeny, which lists what is refused and takes its number.',
595
+ ],
596
+ tone: 'warn',
597
+ });
504
598
  });
599
+ return;
505
600
  }
506
- else {
507
- const scope = decision;
508
- head.resolve({ type: 'approve', scope });
509
- // CFG-28 (§4.2) / EXT-70 §6 — **a scope this dialog confirms is a scope the gate will
510
- // honour.** A sticky approve is reachable only where `grantPreview` is present, which the
511
- // runner attaches exactly where it would store something and withholds for a `catastrophic`
512
- // verdict, a command it cannot statically resolve, and a call it cannot attribute. The keys
513
- // are gated on the same value in `useInput`, so there is no keystroke that arrives here
514
- // asking for a persistence the store will discard — which is why this says what the pressed
515
- // scope means and needs no branch for a promise that could not be kept. **The gate is what
516
- // carries that, not this text:** an honest confirmation does not make an approval the menu
517
- // never offered safe, because the command runs either way.
518
- const describe = () => {
519
- // EXT-71 §3.1 — a grant is exactly the command the human saw, so the confirmation says
520
- // that and not "this operation". A longer command that merely starts with it asks again.
521
- if (scope === 'session')
522
- return 'Approved — this exact command will not ask again this session.';
523
- if (scope === 'always')
524
- return 'Approved and remembered — this exact command is saved to the project allow-list.';
525
- return 'Approved this single invocation only.';
526
- };
601
+ const scope = decision;
602
+ const outcome = head.resolve({ type: 'approve', scope });
603
+ // CFG-28 (§4.2) / EXT-70 §6 — **a scope this dialog confirms is a scope the gate will
604
+ // honour.** A sticky approve is reachable only where `grantPreview` is present, which the
605
+ // runner attaches exactly where it would store something and withholds for a `catastrophic`
606
+ // verdict, a command it cannot statically resolve, and a call it cannot attribute. The keys
607
+ // are gated on the same value in `useInput`, so there is no keystroke that arrives here
608
+ // asking for a persistence the store will discard. **The gate is what carries that, not this
609
+ // text:** an honest confirmation does not make an approval the menu never offered safe,
610
+ // because the command runs either way.
611
+ //
612
+ // [[EXT-150]] — **that argument covers `once` and `session` and stops short of `always`.** What
613
+ // the gate guarantees is that a control it offered has somewhere to store its answer; it says
614
+ // nothing about the store's file being writable, and [[EXT-149]] is the case where the offer was
615
+ // legitimate and the write still failed. `once` persists nothing and `session` is held in
616
+ // memory, so both are true the moment they are written; `always` is a claim about a file and
617
+ // waits for the answer below.
618
+ const describe = (landed) => {
619
+ // EXT-71 §3.1 — a grant is exactly the command the human saw, so the confirmation says
620
+ // that and not "this operation". A longer command that merely starts with it asks again.
621
+ if (landed === 'always')
622
+ return 'Approved and remembered — this exact command is saved to the project allow-list.';
623
+ if (scope === 'always')
624
+ return 'Approved for this session only — it was not written to the project allow-list.';
625
+ if (landed === 'session')
626
+ return 'Approved — this exact command will not ask again this session.';
627
+ return 'Approved this single invocation only.';
628
+ };
629
+ if (scope !== 'always') {
527
630
  push({
528
631
  kind: 'notice',
529
632
  title: `Command approved (${scope})`,
530
- lines: [describe()],
633
+ lines: [describe(scope)],
531
634
  tone: 'info',
532
635
  });
636
+ return;
533
637
  }
534
- setApprovalQueue((q) => q.slice(1));
638
+ void outcome.then((landed) => {
639
+ const savedToProject = landed?.lifetime === 'always';
640
+ push({
641
+ kind: 'notice',
642
+ // The title carries a lifetime too, so it is written from the landed one for the same
643
+ // reason the body is — a heading saying `always` over a body saying *this session only*
644
+ // would be the contradiction moved up one line.
645
+ title: `Command approved (${savedToProject ? 'always' : 'session'})`,
646
+ lines: [describe(savedToProject ? 'always' : 'session')],
647
+ tone: 'info',
648
+ });
649
+ });
535
650
  };
536
651
  const handleSubmit = useCallback((value) => {
537
652
  if (!value.trim())
@@ -632,6 +747,8 @@ export function App(props) {
632
747
  showApprovals();
633
748
  else if ('trust' in result.approvals)
634
749
  applyMcpTrust(result.approvals.trust);
750
+ else if ('undeny' in result.approvals)
751
+ liftRefusal(result.approvals.undeny.index);
635
752
  else
636
753
  applyApprovalRung(result.approvals.rung);
637
754
  }
@@ -690,6 +807,7 @@ export function App(props) {
690
807
  applyApprovalRung,
691
808
  showApprovals,
692
809
  applyMcpTrust,
810
+ liftRefusal,
693
811
  applyMouse,
694
812
  ]);
695
813
  // Keyboard handling, in priority order:
@@ -788,6 +906,17 @@ export function App(props) {
788
906
  // reports as names, and control characters are refused outright rather than escaped: this
789
907
  // buffer is compared against one exact phrase, so anything it cannot match is better dropped
790
908
  // than repaired — and neutralising is the framing renderer's job, not a text field's.
909
+ //
910
+ // **This is the one text buffer that deliberately refuses the whole EVENT, and it does not
911
+ // share `keyGuards.ts`'s answer for that reason.** Everywhere else a pasted chunk keeps its
912
+ // text and loses its control characters, because dropping a paste over one character in it
913
+ // destroys what the user wrote. Here the opposite is true: a terminal delivers a pasted line
914
+ // as one chunk carrying its trailing `\r`, that chunk is not the Enter key, and the phrase
915
+ // matcher trims before it compares — so filtering the character out would leave exactly
916
+ // `run anyway` in the buffer, one Enter away from an irreversible command the human never
917
+ // typed the phrase for. A paste is the one input someone can be talked into producing
918
+ // without reading it, and this field's whole job is to be un-producible by accident
919
+ // (`spec/tui/AttackBanner.spec.tsx`, [[TUI-C68]] §1).
791
920
  if (!chord && input.length > 0 && !/[\p{Cc}]/u.test(input)) {
792
921
  attackTypedRef.current += input;
793
922
  setAttackTyped(attackTypedRef.current);
@@ -906,11 +1035,15 @@ export function App(props) {
906
1035
  key.pageDown) {
907
1036
  return;
908
1037
  }
909
- // A chord belongs to whoever bound it, never to a text buffer — the same four modifiers the
910
- // attack-banner phrase and the prompt's own editor refuse, so every buffer in the TUI reads
911
- // a chord the same way. `shift` is excluded deliberately: it is how a capital is typed.
912
- if (input && !key.ctrl && !key.meta && !key.super && !key.hyper) {
913
- setDebugSearch(debugSearchQueryRef.current + input);
1038
+ // A chord belongs to whoever bound it, never to a text buffer — and neither does a bare
1039
+ // control byte, which carries no modifier for a four-modifier guard to see. The predicate
1040
+ // is the shared one every buffer in the TUI reads its input through (`keyGuards.ts`), so
1041
+ // this pane and the prompt cannot disagree about what a keystroke is. What extends the
1042
+ // query is the event's TEXT: bracketed-paste mode is off while this pane has focus (the
1043
+ // prompt owns `usePaste` and is unmounted here), so a pasted search term arrives as one
1044
+ // event, and a term copied off a line brings that line's newline with it.
1045
+ if (isTypedText(input, key)) {
1046
+ setDebugSearch(debugSearchQueryRef.current + typedText(input, key));
914
1047
  }
915
1048
  return;
916
1049
  }
@@ -1110,6 +1243,27 @@ export function App(props) {
1110
1243
  });
1111
1244
  // eslint-disable-next-line react-hooks/exhaustive-deps
1112
1245
  }, []);
1246
+ // [[EXT-137]] — commit the request's scrollable half into the transcript, once, at the moment it
1247
+ // becomes the one being ASKED.
1248
+ //
1249
+ // On the head rather than on arrival, and the difference shows only when a second call queues
1250
+ // behind the first: printing both blocks up front would leave the dock's "the call is shown
1251
+ // above" pointing at whichever of them the reader happened to look at, which is the one thing
1252
+ // this split must not get wrong. It stays in the transcript afterwards, followed by the decision
1253
+ // notice `resolveApproval` commits, so the pair reads as a record of what was asked and answered.
1254
+ //
1255
+ // The set is what makes it once-only. A re-render with the same head — a keystroke, a status
1256
+ // update, a terminal resize — must not append the block again, and identity of the queue RECORD
1257
+ // is the right key: two identical calls in a row are two records and are two questions.
1258
+ useEffect(() => {
1259
+ if (!pendingApproval)
1260
+ return;
1261
+ if (announcedApprovals.current.has(pendingApproval))
1262
+ return;
1263
+ announcedApprovals.current.add(pendingApproval);
1264
+ push({ kind: 'approval', pending: pendingApproval.pending });
1265
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1266
+ }, [pendingApproval]);
1113
1267
  // [[TUI-C68]] §6.1 — attack halts bridged from the runner. Each one mounts the <AttackBanner>,
1114
1268
  // which owns the keyboard until it is answered. Absent on the fixture path, where the runner
1115
1269
  // halts on its own — an unsubscribed surface keeps the halt, which is what makes forgetting to
@@ -1122,21 +1276,35 @@ export function App(props) {
1122
1276
  });
1123
1277
  // eslint-disable-next-line react-hooks/exhaustive-deps
1124
1278
  }, []);
1279
+ // [[TUI-C69]] §5.4 — the negotiation's rounds bridged from the runner, appended as they are
1280
+ // decided so the argument is watched while it runs rather than only reported at an escalation.
1281
+ // Absent on the fixture path, where nothing negotiates.
1282
+ useEffect(() => {
1283
+ if (!props.subscribeNegotiation)
1284
+ return;
1285
+ return props.subscribeNegotiation((event) => {
1286
+ // `null` is the gate saying the exchange is over. Dropping the rounds here is what stops the
1287
+ // escalation prompt — which renders the whole argument itself — being drawn beneath a live
1288
+ // copy of the same rounds on a screen that cannot scroll.
1289
+ setNegotiationRounds((rounds) => (event === null ? [] : [...rounds, event]));
1290
+ });
1291
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1292
+ }, []);
1125
1293
  // The greeting is an intro, not a permanent fixture: show it only before the first
1126
1294
  // exchange, so it stops padding the bottom dock once the conversation is underway.
1127
1295
  const showIntro = !initialMessage && transcript.length === 0 && !live;
1128
1296
  // Extract active checklist items from live turn or transcript so it stays pinned at the bottom.
1129
1297
  const activeChecklist = useMemo(() => extractActiveChecklist(live, transcript), [live, transcript]);
1130
- return (_jsx(TerminalSizeProvider, { size: terminalSize, children: _jsx(MouseProvider, { subscribe: props.subscribeMouse, enabled: mouseEnabled, children: _jsxs(Box, { flexDirection: "column", height: terminalRows, children: [_jsxs(TranscriptViewport, { items: transcript, budgetRows: terminalRows, columns: terminalColumns, toolsExpanded: toolsExpanded, scroll: transcriptScroll, regionRows: transcriptRegionRows, geometry: transcriptGeometry, children: [clearedBanner ? _jsx(ClearBanner, {}) : null, showIntro && props.showLaunchBanner ? (_jsx(LaunchBanner, { model: modelDisplayName, provider: props.modelProviderType })) : null, showIntro ? _jsx(Text, { dimColor: true, children: readyMessage.trim() }) : null, live ? _jsx(LiveTurn, { turn: live, toolsExpanded: toolsExpanded, streaming: true }) : null] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [debugVisible ? (_jsx(DebugPanel, { subagents: subagents, historyLines: debugHistory, systemLines: debugSystem, toolsLines: debugTools, mcpLines: debugMcp, responseLines: debugResponse, activeTab: debugTab, scrollOffset: debugScroll, focused: debugFocused, viewportHeight: debugViewport, maximized: debugMaximized, searchQuery: debugSearchQuery, searchActive: debugSearchInput, matchCount: debugMatches.length, currentMatchIndex: debugSearchCurrent, currentMatchLine: currentMatchLine })) : null, activeChecklist ? _jsx(ChecklistPanel, { items: activeChecklist }) : null, pendingAttack ? _jsx(AttackBanner, { halt: pendingAttack.halt, typed: attackTyped }) : null, pendingApproval && !pendingAttack ? (_jsx(ApprovalPrompt, { pending: pendingApproval.pending })) : null, approvalsPicker && !pendingApproval && !pendingAttack ? (_jsx(ApprovalsPicker, { choices: approvalsPicker, onSelect: (rung) => {
1298
+ return (_jsx(TerminalSizeProvider, { size: terminalSize, children: _jsx(MouseProvider, { subscribe: props.subscribeMouse, enabled: mouseEnabled, children: _jsxs(Box, { flexDirection: "column", height: terminalRows, children: [_jsxs(TranscriptViewport, { items: transcript, budgetRows: terminalRows, columns: terminalColumns, toolsExpanded: toolsExpanded, scroll: transcriptScroll, regionRows: transcriptRegionRows, geometry: transcriptGeometry, children: [clearedBanner ? _jsx(ClearBanner, {}) : null, showIntro && props.showLaunchBanner ? (_jsx(LaunchBanner, { model: modelDisplayName, provider: props.modelProviderType })) : null, showIntro ? _jsx(Text, { dimColor: true, children: readyMessage.trim() }) : null, live && transcript.length > 0 ? _jsx(BlankRow, {}) : null, live ? _jsx(LiveTurn, { turn: live, toolsExpanded: toolsExpanded, streaming: true }) : null] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [debugVisible ? (_jsx(DebugPanel, { subagents: subagents, historyLines: debugHistory, systemLines: debugSystem, toolsLines: debugTools, mcpLines: debugMcp, responseLines: debugResponse, activeTab: debugTab, scrollOffset: debugScroll, focused: debugFocused, viewportHeight: debugViewport, maximized: debugMaximized, searchQuery: debugSearchQuery, searchActive: debugSearchInput, matchCount: debugMatches.length, currentMatchIndex: debugSearchCurrent, currentMatchLine: currentMatchLine })) : null, activeChecklist ? _jsx(ChecklistPanel, { items: activeChecklist }) : null, _jsx(NegotiationPanel, { rounds: negotiationRounds }), pendingAttack ? _jsx(AttackBanner, { halt: pendingAttack.halt, typed: attackTyped }) : null, pendingApproval && !pendingAttack ? (_jsx(ApprovalPrompt, { pending: pendingApproval.pending })) : null, approvalsPicker && !pendingApproval && !pendingAttack ? (_jsx(ApprovalsPicker, { choices: approvalsPicker, onSelect: (rung) => {
1131
1299
  setApprovalsPicker(null);
1132
1300
  applyApprovalRung(rung);
1133
- }, onCancel: () => setApprovalsPicker(null) })) : null, _jsx(Rule, {}), _jsx(NoticeBar, { advisories: props.advisories }), _jsx(McpFailureBar, { failures: props.mcpFailures }), _jsx(StatusBar, { running: running, mode: mode, modelDisplayName: modelDisplayName, turnCount: turnCount, debugHint: debugVisible && !debugFocused, approvals: approvals
1301
+ }, onCancel: () => setApprovalsPicker(null) })) : null, _jsx(BlankRow, {}), _jsx(Rule, {}), _jsx(NoticeBar, { advisories: props.advisories }), _jsx(McpFailureBar, { failures: props.mcpFailures }), _jsx(StatusBar, { running: running, mode: mode, modelDisplayName: modelDisplayName, turnCount: turnCount, debugHint: debugVisible && !debugFocused, approvals: approvals
1134
1302
  ? {
1135
1303
  rung: approvals.rung,
1136
1304
  raterProfile: approvals.rater,
1137
1305
  }
1138
- : undefined }), !debugFocused && !pendingApproval && !pendingAttack && !approvalsPicker ? (_jsx(PromptInput, { onSubmit: handleSubmit, commands: registry, onMenuStateChange: (active) => {
1139
- slashMenuActiveRef.current = active;
1140
- }, handleRef: promptHandleRef })) : null, _jsx(Text, { dimColor: true, children: `${exitMessage.trim()}${TUI_HINT_SUFFIX}` }), _jsx(Rule, {})] })] }) }) }));
1306
+ : undefined }), !debugFocused && !pendingApproval && !pendingAttack && !approvalsPicker ? (_jsxs(_Fragment, { children: [_jsx(BlankRow, {}), _jsx(PromptInput, { onSubmit: handleSubmit, commands: registry, onMenuStateChange: (active) => {
1307
+ slashMenuActiveRef.current = active;
1308
+ }, handleRef: promptHandleRef, draftCarryRef: promptDraftCarryRef }), _jsx(BlankRow, {})] })) : null, _jsx(Text, { dimColor: true, children: `${exitMessage.trim()}${TUI_HINT_SUFFIX}` }), _jsx(Rule, {})] })] }) }) }));
1141
1309
  }
1142
1310
  //# sourceMappingURL=App.js.map