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.
- package/LICENSE +6 -6
- package/dist/cli.js +6 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/configSetup.d.ts +13 -1
- package/dist/commands/configSetup.js +27 -6
- package/dist/commands/configSetup.js.map +1 -1
- package/dist/commands/firstRunDialog.d.ts +8 -3
- package/dist/commands/firstRunDialog.js +41 -13
- package/dist/commands/firstRunDialog.js.map +1 -1
- package/dist/commands/initCommand.d.ts +11 -1
- package/dist/commands/initCommand.js +21 -4
- package/dist/commands/initCommand.js.map +1 -1
- package/dist/commands/prDiscovery.js +14 -1
- package/dist/commands/prDiscovery.js.map +1 -1
- package/dist/modules/startSession.js +4 -1
- package/dist/modules/startSession.js.map +1 -1
- package/dist/tui/components/App.js +228 -60
- package/dist/tui/components/App.js.map +1 -1
- package/dist/tui/components/ApprovalPrompt.d.ts +51 -33
- package/dist/tui/components/ApprovalPrompt.js +55 -140
- package/dist/tui/components/ApprovalPrompt.js.map +1 -1
- package/dist/tui/components/ApprovalRequestPanel.d.ts +28 -0
- package/dist/tui/components/ApprovalRequestPanel.js +60 -0
- package/dist/tui/components/ApprovalRequestPanel.js.map +1 -0
- package/dist/tui/components/BlankRow.d.ts +18 -0
- package/dist/tui/components/BlankRow.js +22 -0
- package/dist/tui/components/BlankRow.js.map +1 -0
- package/dist/tui/components/LiveTurn.js +31 -19
- package/dist/tui/components/LiveTurn.js.map +1 -1
- package/dist/tui/components/NegotiationPanel.d.ts +32 -0
- package/dist/tui/components/NegotiationPanel.js +52 -0
- package/dist/tui/components/NegotiationPanel.js.map +1 -0
- package/dist/tui/components/PromptEditor.d.ts +10 -1
- package/dist/tui/components/PromptEditor.js +52 -18
- package/dist/tui/components/PromptEditor.js.map +1 -1
- package/dist/tui/components/PromptInput.d.ts +46 -1
- package/dist/tui/components/PromptInput.js +270 -9
- package/dist/tui/components/PromptInput.js.map +1 -1
- package/dist/tui/components/SelectList.js +7 -5
- package/dist/tui/components/SelectList.js.map +1 -1
- package/dist/tui/components/TranscriptViewport.d.ts +2 -1
- package/dist/tui/components/TranscriptViewport.js +10 -3
- package/dist/tui/components/TranscriptViewport.js.map +1 -1
- package/dist/tui/keyBindings.js +7 -0
- package/dist/tui/keyBindings.js.map +1 -1
- package/dist/tui/keyGuards.d.ts +126 -0
- package/dist/tui/keyGuards.js +141 -0
- package/dist/tui/keyGuards.js.map +1 -0
- package/dist/tui/pasteParser.d.ts +2 -1
- package/dist/tui/pasteParser.js +2 -1
- package/dist/tui/pasteParser.js.map +1 -1
- package/dist/tui/transcriptWindow.d.ts +6 -0
- package/dist/tui/transcriptWindow.js +33 -4
- package/dist/tui/transcriptWindow.js.map +1 -1
- package/dist/tui/tuiSessionModule.js +92 -7
- package/dist/tui/tuiSessionModule.js.map +1 -1
- package/dist/tui/types.d.ts +51 -5
- package/dist/tui/viewModel.d.ts +8 -0
- package/dist/tui/viewModel.js +1 -0
- package/dist/tui/viewModel.js.map +1 -1
- 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,
|
|
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,
|
|
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
|
|
477
|
-
// command
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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 —
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
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
|