Repository navigation
Expand file tree
/
Copy pathconnect.ts
More file actions
6381 lines (6227 loc) · 276 KB
/
Copy pathconnect.ts
File metadata and controls
6381 lines (6227 loc) · 276 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* `brigade connect` — TUI that talks to a running `brigade gateway`.
*
* This is the thin client. The gateway owns the Pi session, runs the full
* 6-layer wrapper composition, and broadcasts every Pi event back to us.
* Our job is only to render and to forward the user's input as typed
* requests over the WebSocket.
*
* Feature parity with `brigade chat` for the common path (send a message,
* stream the reply, see tool calls, abort with Ctrl+C, switch model, set
* thinking level, compact, exit).
*
* `/provider` works here too: switching among configured providers reuses the
* `set-model` RPC, and adding a new API-key provider inline routes the typed
* key to the gateway's `add-provider` RPC, which validates + persists it on
* the GATEWAY side (auth-profiles.json) and hot-refreshes the model registry.
* Because the credential write happens server-side, the TUI client never needs
* gateway-filesystem access. Subscription / local / custom providers (OAuth,
* Ollama, BYO-endpoint) still need the full `brigade onboard` wizard.
*/
import { COPILOT_AUTO_MODEL_ID } from "../../agents/github-copilot-transport.js";
import { describeReasoningVisibility } from "../../agents/reasoning/visibility.js";
import type { ProviderLimitWindow } from "../../agents/usage/limits.js";
import { PI_TOOL_CALL, PI_TOOL_RESULT } from "../../agents/pi-dialect.js";
import process from "node:process";
import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import { resolveStateDir } from "../../config/paths.js";
import { exportFileName, renderTranscriptMarkdown } from "../../ui/transcript-export.js";
import { isUnknownCommandAttempt, nearestSlashCommand } from "../../ui/slash-suggest.js";
import { searchTranscript } from "../../ui/transcript-search.js";
import { describeRedactions, redactForExport } from "../../ui/transcript-redact.js";
import { sanitizeSessionName } from "../../sessions/session-store.js";
import * as nodePath from "node:path";
import {
CancellableLoader,
CombinedAutocompleteProvider,
isKittyProtocolActive,
ProcessTerminal,
type SlashCommand,
Text,
TUI,
} from "@earendil-works/pi-tui";
import { BrigadeEditor } from "../../ui/editor.js";
import {
attachmentIcon,
extractAttachmentPaths,
formatBytes as formatAttachmentBytes,
MAX_STAGED_ATTACHMENTS,
readClipboardAttachments,
stageAttachment,
stopClipboard,
toPromptAttachments,
warmClipboard,
type StagedAttachment,
} from "../../ui/attachments.js";
import { PROVIDERS, findProvider } from "../../providers/catalog.js";
import { sanitizeTerminalInput } from "../../security/terminal-input-sanitizer.js";
import chalk from "chalk";
// Brigade's `Markdown` is a thin Pi-TUI subclass that normalizes `_text_`
// italic spans to `*text*` so the renderer applies italic styling instead
// of leaking literal underscores. Same shape as Pi-TUI's `Markdown` — drop-in.
import { Markdown } from "../../ui/markdown.js";
import { loaderIndicator, probeTerminalAnimationSupport } from "../../ui/animations.js";
import { formatReasoningLine, formatTokens } from "../../agents/reasoning/format.js";
import { formatCostSegment, formatUsd} from "../../agents/usage/format.js";
import { describeRetry } from "../../protocol/errors.js";
import type { SessionListRow } from "../../protocol/methods.js";
import { mutateConfigAtomic, readConfigOrInit } from "../../config/io.js";
import { renderBrandHeader } from "../../ui/brand.js";
import { formatCrewLabel, formatSessionLabel } from "../../ui/format-session.js";
import { markTuiActive, restoreTerminal } from "../../ui/terminal-cleanup.js";
import { brand, editorTheme, markdownTheme } from "../../ui/theme.js";
import { summarizeToolResult, formatToolArgs, looksLikeUnifiedDiff, classifyDiffLine, summarizeDiffStats, parseTodoArgs, todoMarker, summarizeTodos, isShellLikeTool, tailLines, describeOutputSize} from "../../ui/tool-result.js";
import { notifyTurnComplete } from "../../ui/notify.js";
import { buildOsc52, lastCodeBlock } from "../../ui/osc52.js";
import { BrigadeClient } from "../../tui/client.js";
import { DEFAULT_AGENT_ID } from "../../config/paths.js";
import { loadConfig } from "../../core/config.js";
import { getBuildInfo } from "../../version.js";
import { resolveClientToken } from "../../core/gateway-auth.js";
import { asstKey, clipOneLine, extractUserText } from "./connect-transcript.js";
import { UPDATE_PRESERVES_MESSAGE } from "../../core/update-check.js";
import { runUpdateCommand } from "./update.js";
import { ApprovalPrompt, type ApprovalResolution } from "../../tui/approval-prompt.js";
import type {
AgentSummary,
EventPayload,
ModelSummary,
PromptAttachment,
SessionDeleteResult,
SessionRenameResult,
UsageSummaryResult,
SessionRewindResult,
SessionStateSnapshot,
SessionSummary,
WireMessage,
} from "../../protocol.js";
import {
computeExplain,
filterGraphToSubtree,
formatExplain,
parseOrgSlash,
renderDepartmentsOnly,
} from "./org-slash.js";
import {
renderPrideChartWithPins,
BRIGADE_FOOTER_RULE,
} from "../../agents/org/pride-template.js";
import type { MemoryQueryResult } from "../../agents/memory/query.js";
import type { OrgGraph } from "../../agents/org/types.js";
// Commander wrapper — `brigade connect` is the thin TUI client that
// connects to a running gateway. Same single-touch pattern the TUI /
// gateway commands use.
export function registerConnectCommand(program: import("commander").Command): void {
program
.command("connect")
.description("Connect to a running Brigade gateway from a thin TUI client")
.option("-h, --host <host>", "gateway host (default: 127.0.0.1)")
.option("-p, --port <port>", "gateway port", (v) => parseInt(v, 10))
.option("--timeout <ms>", "request timeout in ms", (v) => parseInt(v, 10))
.action(async (opts: { host?: string; port?: number; timeout?: number }) => {
await runConnectCommand({
host: opts.host,
port: opts.port,
requestTimeoutMs: opts.timeout,
});
await new Promise<void>(() => {});
});
}
export interface ConnectCommandOptions {
/** Gateway host. Default: 127.0.0.1 */
host?: string;
/** Gateway port. Default: 7777 */
port?: number;
/** Per-request timeout (ms). Default: 60_000 */
requestTimeoutMs?: number;
/**
* Token for an authenticated gateway. Falls back to `BRIGADE_GATEWAY_TOKEN`
* then the local `gateway.auth` config when omitted; undefined for an
* unauthenticated gateway (the default), in which case no auth is sent.
*/
token?: string;
/**
* Bind the TUI to this agent id at startup — equivalent to opening the
* TUI and immediately running `/agent <id>`, but without the manual step.
* Validated against the gateway's `agents.list` before the UI engages;
* an unknown id exits with the available list (an unvalidated id would
* silently fall back to the boot agent server-side, so the operator
* would think they were talking to X while actually talking to main).
*/
agentId?: string;
/**
* Open straight into an existing THREAD — `brigade tui --session t-0bf7c8e1`,
* equivalent to launching and immediately running `/session <key>`.
*
* Accepts the short label the header shows (`t-0bf7c8e1`) or the canonical key
* (`agent:main:t-0bf7c8e1`); the short form is expanded against the bound agent.
* Validated against `sessions.list` BEFORE the UI engages: an unknown key would
* otherwise create a brand-new EMPTY thread of that name, and the operator's next
* message — "please continue" — would land in a conversation that has never seen a
* word, while their real thread sat untouched on disk.
*
* Binding is what gives the AGENT its context: every prompt carries this session
* key, the gateway resolves that session, and the turn runs against its history.
*/
sessionKey?: string;
}
export interface ConnectHandle {
/** Aborts the in-flight turn (true) or signals "no turn was running" (false). */
abort(): boolean;
close(): Promise<void>;
}
/**
* Should a `state` snapshot's `sessionKey` seed the connection-bound session?
*
* Yes when unbound (normal first-snapshot seed) or when the snapshot is for
* the SAME agent we're bound to. No when bound to a DIFFERENT agent than the
* snapshot — that's the `--agent X` cross-agent-session bug: the gateway's
* boot snapshot (agent `main`, session `agent:main:main`) must NOT seed the
* session for a connection bound to `marketing-lead`, or `withBinding` emits
* the incoherent pair `{agentId: marketing-lead, sessionKey: agent:main:main}`
* and the reply gets filtered to the wrong lane. Exported for regression
* testing of that exact decision.
*/
export function snapshotSessionSeedable(
boundAgentId: string | undefined,
snapAgentId: string | undefined,
): boolean {
if (boundAgentId === undefined) return true;
return typeof snapAgentId === "string" && snapAgentId === boundAgentId;
}
/**
* Boot the connect TUI. Establishes the WebSocket connection FIRST so the
* user gets a clear "couldn't reach gateway" error instead of a blank chat.
*/
export async function runConnectCommand(opts: ConnectCommandOptions = {}): Promise<ConnectHandle> {
// Connect runs a TUI client (raw mode, alt-screen, kitty keys) — opt into
// the on-exit terminal cleanup. Non-TUI commands skip this and exit silently.
markTuiActive();
const host = opts.host ?? "127.0.0.1";
const port = opts.port ?? 7777;
const url = `ws://${host}:${port}`;
// Ask the terminal whether it can repaint atomically (DECRQM 2026) while
// stdin is still ours — decides animated vs static chrome. ≤150 ms once.
await probeTerminalAnimationSupport();
const tui = new TUI(new ProcessTerminal());
tui.start();
// Wire SIGINT BEFORE the connect attempt so Ctrl+C during a slow connect
// exits cleanly instead of hanging on the open promise. Re-arm via
// process.once after each turn-abort so handlers never stack across
// re-invocations within the same process.
let chatHandle: ConnectHandle | null = null;
const onSigint = (): void => {
if (chatHandle) {
const wasRunning = chatHandle.abort();
if (!wasRunning) {
// `.finally`, not `.then`: this listener is a `once` and is already
// consumed here, so a rejected close() would leave the TUI with Ctrl+C
// permanently dead and no way out but another terminal. Exit either way.
void chatHandle.close().finally(() => {
tui.stop();
// tui.stop() already runs Pi-TUI's cleanup (kitty pop on
// the happy path); restoreTerminal() is the broader safety
// net (focus, mouse, alt-screen, modifyOtherKeys) plus an
// unconditional kitty pop in case Pi-TUI's flag tracker
// missed it.
restoreTerminal();
process.exit(0);
});
return;
}
process.once("SIGINT", onSigint); // re-arm for the next Ctrl+C
return;
}
tui.stop();
restoreTerminal();
process.exit(130);
};
process.once("SIGINT", onSigint);
// Resolve the gateway token (flag → BRIGADE_GATEWAY_TOKEN → local config).
// Undefined when the gateway is unauthenticated — the client then sends no
// auth header and connects exactly as before.
const token = resolveClientToken(loadConfig().gateway?.auth, { override: opts.token });
const client = new BrigadeClient({
url,
requestTimeoutMs: opts.requestTimeoutMs ?? 60_000,
...(token ? { token } : {}),
});
try {
await client.connect();
} catch (err) {
tui.stop();
restoreTerminal();
const msg = err instanceof Error ? err.message : String(err);
const isRefused = /ECONNREFUSED|connect.*refused/i.test(msg);
const isTimeout = /ETIMEDOUT|timed?\s*out/i.test(msg);
const isUnknownHost = /ENOTFOUND|EAI_AGAIN|getaddrinfo|temporary failure in name resolution/i.test(msg);
const isUnreachable = /EHOSTUNREACH|ENETUNREACH|network is unreachable|no route to host/i.test(msg);
// Headline: don't interpolate the raw `msg` (it can carry ENOTFOUND-style
// internal codes and getaddrinfo jargon). Just say what we tried.
console.error(chalk.red(`✗ Couldn't reach the Brigade gateway at ${url}.`));
// Differentiate the most common failure modes so users don't waste
// time debugging the wrong cause.
if (isRefused) {
console.error(chalk.dim(` Likely cause: no gateway is running on port ${port}.`));
console.error(chalk.dim(` Either start one: brigade gateway --port ${port}`));
console.error(chalk.dim(` Or, if it's on another port: brigade connect --port <that-port>`));
} else if (isTimeout) {
console.error(chalk.dim(` Likely cause: gateway is reachable but slow to handshake.`));
console.error(chalk.dim(` Check that ${host} resolves and the port isn't blocked by a firewall.`));
} else if (isUnknownHost) {
console.error(chalk.dim(` Couldn't resolve "${host}" — check the hostname and your DNS.`));
} else if (isUnreachable) {
console.error(chalk.dim(` No network route to ${host}. Check your VPN / firewall / network connection.`));
} else {
console.error(chalk.dim(` Start the gateway first: brigade gateway --port ${port}`));
console.error(chalk.dim(` Or check the host/port flags: brigade connect --host ${host} --port ${port}`));
}
// Surface the raw message only when the operator opts into debug mode.
if (process.env.BRIGADE_DEBUG === "1") {
console.error(chalk.dim(` (debug: ${msg})`));
}
process.exit(1);
}
// Startup `--agent <id>` binding. Validate against the gateway's live
// agent list BEFORE engaging the UI — an unknown id would otherwise fall
// back to the boot agent server-side (getAgentRuntime), silently routing
// the operator's turns to `main` while they believe they're talking to X.
// Mirrors the in-TUI `/agent` validation. agents.list failure is lenient
// (bind anyway; the in-TUI /agent can correct) so a transient RPC hiccup
// doesn't block launch.
let initialAgentId: string | undefined;
if (opts.agentId) {
try {
const known = (await client.request("agents.list")) as AgentSummary[];
if (!known.some((a) => a.id === opts.agentId)) {
tui.stop();
restoreTerminal();
const available = known.map((a) => a.id).join(", ") || "(none)";
console.error(chalk.red(`✗ Unknown agent "${opts.agentId}".`));
console.error(chalk.dim(` Available: ${available}`));
console.error(chalk.dim(` (run \`brigade agents list\` to see them all)`));
process.exit(1);
}
initialAgentId = opts.agentId;
} catch {
// Lenient: bind anyway; `/agent` inside the TUI re-validates.
initialAgentId = opts.agentId;
}
}
// Startup `--session <key>` binding. Same discipline as `--agent`, for a sharper
// reason: an unknown AGENT falls back to `main` — wrong, but a real conversation.
// An unknown SESSION KEY creates a brand-new EMPTY thread under that name, so
// "please continue" lands in a conversation that has never seen a word while the
// real thread sits on disk untouched. Resolve it, verify it, or stop.
let initialSessionKey: string | undefined;
if (opts.sessionKey) {
const agentForKey = initialAgentId ?? DEFAULT_AGENT_ID;
const target = opts.sessionKey.startsWith("agent:")
? opts.sessionKey
: `agent:${agentForKey}:${opts.sessionKey}`;
try {
const res: unknown = await client.request(
"sessions.list",
initialAgentId !== undefined ? { agentId: initialAgentId } : {},
);
const list = Array.isArray(res)
? (res as SessionSummary[])
: ((res as { sessions?: SessionSummary[] }).sessions ?? []);
if (!list.some((s) => s.sessionKey === target)) {
tui.stop();
restoreTerminal();
console.error(chalk.red(`✗ No thread with key "${target}".`));
console.error(chalk.dim(" Opening an unknown key would start an EMPTY thread of that name."));
const available = list.map((s) => s.sessionKey).slice(0, 12);
console.error(chalk.dim(` Your threads:\n ${available.join("\n ") || "(none)"}`));
console.error(chalk.dim(" (`brigade sessions list` shows them all)"));
process.exit(1);
}
initialSessionKey = target;
} catch {
// Lenient, exactly like `--agent`: a transient RPC hiccup must not block a
// launch. `/session` inside the TUI re-validates.
initialSessionKey = target;
}
}
chatHandle = await wireConnectUi(tui, client, initialAgentId, initialSessionKey, host);
return chatHandle;
}
/**
* Build the live chat UI on top of an already-connected client. Split out so
* tests can inject a pre-connected client without going through CLI flag
* parsing or process.exit.
*/
export async function wireConnectUi(
tui: TUI,
client: BrigadeClient,
initialAgentId?: string,
initialSessionKey?: string,
/**
* The gateway host this client is attached to. Only file attachments care:
* they travel as PATHS, so they are meaningful only when the gateway shares
* this machine's filesystem. Defaults to loopback — the shape every existing
* caller (and every test) already gets.
*/
gatewayHost = "127.0.0.1",
): Promise<ConnectHandle> {
// Static (last-frame) wordmark — `brigade connect` is the chat surface
// just like `brigade chat`, so we want the same still rendering here. The
// looping clip is reserved for onboarding's one-time wow moment.
renderBrandHeader(tui, { animate: false });
const header = new Text("", 0, 0);
tui.addChild(header);
const divider = new Text(brand.dim("─".repeat(80)), 0, 0);
tui.addChild(divider);
// Persistent status FOOTER. Pi-TUI renders a viewport anchored to the
// focused editor at the bottom, so the top header (brand wordmark + this
// model/token line) scrolls ABOVE the viewport as soon as a streamed reply
// fills the screen — the operator loses sight of the model + token/cost
// readout exactly when a turn is running. The footer lives in the
// always-visible bottom region (added after the editor, alongside the hint
// lines), and `updateHeader` paints the same live status into it. Declared
// here so the first `updateHeader()` (before the editor is built) can target
// it; added to the tree below, right under the editor.
const footer = new Text("", 0, 0);
// Cumulative usage — accumulated from state snapshots so a reconnect picks
// up where we left off instead of zeroing the totals on the user's screen.
let lastSnapshot: SessionStateSnapshot | null = null;
// The last text the user sent as a prompt — the replay message for a `/switch`
// (Carrow) mid-turn model handoff: abort the live turn, swap, re-run this on the new model.
let lastUserPrompt = "";
// …and the files that rode it. Replaying the text alone would silently drop the
// image, which is fatal to the single most common reason to switch mid-turn:
// the current model can't see it, so move to one that can.
let lastUserAttachments: PromptAttachment[] = [];
let isAgentRunning = false;
/**
* Set by Ctrl/Cmd+Enter, consumed by the very next submit.
*
* A one-shot flag rather than a mode: steering is the exceptional gesture,
* so it must be re-asserted every time rather than left armed where a later
* reflex Enter would inherit it.
*/
/**
* How many messages `resume` returns at most. `/export` and `/search` both
* read through it, so both must say when the head was clipped rather than
* presenting a window as the whole conversation.
*/
const RESUME_TRANSCRIPT_WINDOW = 200;
let steerNextSubmit = false;
/** The mid-turn queue/steer tip is shown once per session, never again. */
let midTurnHintShown = false;
/**
* Re-entrancy guard against a double-tapped Enter.
*
* The submit path is async (an RPC round-trip), so two fast Enters can both
* pass the `isAgentRunning` gate and send the same text twice. opencode uses
* a `submitting` flag for exactly this and Cline's boolean-returning handler
* is the same idea after a duplicate-execution bug.
*/
let submitInFlight = false;
// `/provider <name>` for an UNCONFIGURED provider arms this — the NEXT line
// the operator submits is captured as that provider's API key (sent to the
// gateway's `add-provider`, never echoed into the transcript or input
// history) instead of being sent as a chat prompt. Cleared on submit/cancel.
let pendingProviderEntry: { providerId: string; providerName: string } | null = null;
// Connection-bound agent id. Defaults to the gateway's boot agent (filled
// in from the first `state` snapshot) so legacy single-agent gateways keep
// working unchanged. The `/agent <id>` slash command rebinds the connection
// so subsequent prompt / abort / steer / set-model / set-thinking RPCs all
// target that agent without the operator having to repeat it every turn.
// Seeded from `--agent <id>` when supplied (already validated against
// agents.list in runConnectCommand); otherwise filled from the first
// `state` snapshot. A pre-set value is preserved by the snapshot handler's
// `=== undefined` guard, so the startup binding sticks.
let boundAgentId: string | undefined = initialAgentId;
// Wave K — connection-bound session key. Lets the operator point this
// TUI at a per-peer session (e.g. a channel-routed turn under
// `agent:main:whatsapp:<jid>`) so abort / steer / compact / set-model
// target THAT lane instead of the boot agent's `main`. Seeded from the
// first snapshot, overridden by `/session <key>`.
// Seeded by `--session <key>` (already resolved + verified against sessions.list).
// Left undefined, the first `state` snapshot seeds it with the agent's main thread.
let boundSessionKey: string | undefined = initialSessionKey;
// A name typed before the thread exists on the gateway. `/new` mints a key
// CLIENT-side and no store entry is written until the first turn, so
// "start a thread and name it" — the most natural order — had nothing to
// rename. Hold the name and apply it once the turn creates the entry.
//
// Bound to a SPECIFIC key, and re-checked at apply time: if the operator
// moves to another thread first, this must never land on the wrong one.
let pendingRename: { sessionKey: string; name: string } | undefined;
// Key awaiting a confirming repeat of `/delete`. Cleared on any other
// `/delete` form so a stale confirmation can never execute later.
let pendingDeleteKey: string | undefined;
/** Disarm a `/delete` confirmation. Called on EVERY other submitted line, so
* an armed key cannot survive unrelated work and fire on a later repeat that
* the operator expected to re-arm rather than execute. */
const disarmPendingDelete = (): void => {
pendingDeleteKey = undefined;
};
// Residual P0 (post-Wave K integration audit) — the WS broadcast filter
// at server.ts:903 keys on per-connection subscription Sets populated by
// the `subscribe` RPC. Without an explicit subscribe, the filter falls
// through to back-compat "deliver everything", so two operators each
// running /agent <id> still see each other's pi/log/approval frames.
// Track the last-committed sub pair so we can `unsubscribe` it before
// re-subscribing to the new binding (server keeps sets — leaving stale
// entries widens what the operator sees).
let lastSubscribedAgentId: string | undefined = undefined;
let lastSubscribedSessionKey: string | undefined = undefined;
// Wave N3 (bug #3) — track whether the very first `state` snapshot has
// already seeded the connection bindings AND fired an initial `subscribe`.
// Without this, the snapshot handler stamped boundAgentId/boundSessionKey
// but never called applySubscription(), so the gateway's per-connection
// filter at server.ts:903 fell through to its back-compat "deliver
// everything" branch — every operator on a multi-agent gateway saw every
// other agent's pi/log/approval frames until they manually issued /agent
// or /session. We now fire applySubscription() exactly once on the first
// non-trivial seed; subsequent snapshots are no-ops on this code path.
let seededSubscription = false;
const applySubscription = async (): Promise<void> => {
try {
const priorParams: Record<string, string> = {};
if (lastSubscribedAgentId !== undefined)
priorParams.agentId = lastSubscribedAgentId;
if (lastSubscribedSessionKey !== undefined)
priorParams.sessionId = lastSubscribedSessionKey;
if (Object.keys(priorParams).length > 0) {
await client.request("unsubscribe", priorParams);
}
const nextParams: Record<string, string | boolean> = {};
if (boundAgentId !== undefined) nextParams.agentId = boundAgentId;
if (boundSessionKey !== undefined) nextParams.sessionId = boundSessionKey;
// ASK FOR DELTAS EXPLICITLY.
//
// The gateway sends full frames unless a client opts in, because
// stripping `message.content` from a client that has never heard of
// deltas leaves it rendering nothing until `message_end` — and the
// desktop app, the watch app and every third-party consumer are exactly
// that client. Brigade's own TUI knows how to append, so it asks.
//
// The renderer below handles BOTH shapes — it appends when a frame
// carries a delta and no content, and reconciles from the snapshot
// whenever content IS present — so an older gateway that ignores this
// flag and sends full frames works unchanged.
if (Object.keys(nextParams).length > 0) {
nextParams.deltas = true;
await client.request("subscribe", nextParams);
}
lastSubscribedAgentId = boundAgentId;
lastSubscribedSessionKey = boundSessionKey;
} catch {
// Best-effort. Server falls back to the back-compat branch on
// failure (deliver everything), which matches the pre-fix surface.
}
};
// Wave N3 (bug #3) — defence-in-depth lane filter. Even with the
// server-side `subscribe` RPC engaged, a stale frame can leak during the
// gap between /agent or /session being typed and the gateway processing
// the new subscription set, or against a legacy gateway that doesn't
// honour subscribe at all. Drop any frame whose stamped agentId/sessionId
// disagrees with the operator's currently bound lane. Mirrors the
// upstream client-side gate pattern but adapted for Brigade's
// server-side subscribe (so we only drop frames that explicitly stamp a
// DIFFERENT lane — frames with no stamp at all fall through, preserving
// back-compat with older gateway builds that don't tag broadcasts yet).
const isOffLane = (frameAgentId?: string, frameSessionId?: string, subagentDepth?: number): boolean => {
if (boundAgentId !== undefined && typeof frameAgentId === "string" && frameAgentId.length > 0) {
if (frameAgentId !== boundAgentId) return true;
}
// A SUB-AGENT of the bound agent is in-lane regardless of its session id.
// Sub-agent STREAMING pi frames carry a child Pi-session UUID (NOT the
// `…:subagent:…` descendant key), so the session-prefix check below can't
// catch them — the depth tag is the reliable signal. Without this, the
// operator watching the parent turn never sees the sub-agent working
// (only its approval prompts, which DO carry the descendant key, slipped
// through). The agent already matched above, so this can't leak another
// agent's sub-agents.
if (typeof subagentDepth === "number" && subagentDepth > 0) return false;
if (boundSessionKey !== undefined && typeof frameSessionId === "string" && frameSessionId.length > 0) {
// In-lane = the bound session OR a sub-agent DESCENDANT of it. A
// spawned sub-agent runs under a child key (`<bound>:subagent:<id>`),
// so its live frames + approval prompts belong to the operator
// watching this lane — without this they'd be dropped, the sub-agent's
// work would be invisible, and its `bash` approval prompt would never
// render (the turn hangs until the approval times out). Trailing ":"
// prevents a sibling (`…:main2`) from matching (`…:main`).
if (frameSessionId !== boundSessionKey && !frameSessionId.startsWith(`${boundSessionKey}:`)) return true;
}
return false;
};
// SECURITY (render-side escape scrub). The operator's OWN input is already
// scrubbed at the submit chokepoint (`sanitizeTerminalInput` in
// `editor.onSubmit`), but every string the GATEWAY pushes — tool-result
// previews, assistant text, log lines, cron system-events, the bash command
// echoed back in an approval confirmation — is attacker-influenceable (a
// model/tool can be steered to emit control bytes) and reaches a `Text` /
// `Markdown` widget that preserves raw ANSI. Embedded ESC[ / OSC sequences
// would otherwise move the cursor, clear the screen, rewrite the window
// title, or smuggle an OSC 52 clipboard write. Funnel every server-pushed
// render string through this single helper before it hits a widget.
//
// `sanitizeTerminalInput` already strips CSI/OSC/leaked-paste/stray-ESC, but
// it does NOT strip non-ESC C0 (0x00-0x1F, minus \n/\t) or C1 (0x80-0x9F)
// control bytes — those can still drive a terminal on their own — so we
// remove them here too. Newlines and tabs are preserved (multi-line tool
// errors + the `brigade exec allow …` call-to-action must keep their
// layout). Idempotent + pure; safe to apply to already-clean text.
const scrubRenderable = (text: string): string => {
if (!text) return text;
// First reuse the input-side stripper (CSI/OSC/leaked-paste/stray-ESC +
// lone-surrogate repair), then drop remaining bare C0/C1 control bytes.
// eslint-disable-next-line no-control-regex
return sanitizeTerminalInput(text).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
};
// Build the standard `{ agentId?, sessionKey?, ...rest }` payload shape
// every RPC uses. Keeps the call sites compact and ensures sessionKey
// is threaded uniformly so the server's per-session targeting works.
const withBinding = <T extends Record<string, unknown>>(extra: T = {} as T)
: T & { agentId?: string; sessionKey?: string } => ({
...(boundAgentId !== undefined ? { agentId: boundAgentId } : {}),
...(boundSessionKey !== undefined ? { sessionKey: boundSessionKey } : {}),
...extra,
});
// Streaming-assistant buffers keyed by MESSAGE IDENTITY, not arrival
// position. The key is `${depth}:${timestamp}` — Pi stamps each assistant
// message with a stable `timestamp` at creation that is constant across all
// its `message_update`s and its `message_end`, and a NEW message (e.g. the
// continuation after a tool call) gets a NEW timestamp. So each logical
// message owns exactly one growing Markdown block, and a block lands where
// its message belongs in the stream — never above a tool it came after, and
// a late/duplicate update for an earlier message updates THAT block in place
// instead of spawning a misplaced copy. This identity keying is also what
// makes `resume` idempotent: re-applying a message the client already has
// resolves to the same block. Depth keeps sub-agent (≥1) streams from
// colliding with the top-level (0) stream. Cleared wholesale on `agent_end`
// / abort (turn boundary). `pendingTools` is already identity-keyed by the
// tool call id.
const activeAssistants = new Map<string, Markdown>();
let activeLoader: CancellableLoader | null = null;
const pendingTools = new Map<string, Text>();
/**
* What each tool call was ABOUT, and what it actually returned.
*
* Two jobs. The completed `✓` chip re-renders with `setText`, which would
* otherwise drop the argument summary the `⚡` chip showed — so the args are
* kept and re-applied. And the full result text is retained so `/expand` can
* show it: previews are hard-truncated at 120 chars (800 for errors) and the
* remainder used to be discarded on arrival, which silently swallowed
* citations, diffs and any long command output, unrecoverably.
*
* Bounded — this holds tool output, which can be large.
*/
/** Best-effort text for a non-string tool result. Never throws on a cycle. */
const safeStringify = (v: unknown): string | undefined => {
if (v === null || v === undefined) return undefined;
if (typeof v === "string") return v;
try {
return JSON.stringify(v, null, 2);
} catch {
return String(v);
}
};
/**
* Partial tool-call arguments, keyed by the message's `contentIndex`.
*
* Lives separately from `pendingTools` (keyed by `toolCallId`) because the
* call id is not minted until the arguments are COMPLETE — the whole point
* here is the window before that.
*/
/**
* Reconstructed assistant text per render key, for delta streaming.
*
* In delta mode a `message_update` arrives without `message.content`, so the
* text has to be rebuilt by appending. Keyed identically to the render blocks
* (`asstKey`), so a delta always lands on the block it belongs to.
*
* `message_start` and `message_end` still carry full content and RESET this
* from the authoritative snapshot, so any drift is corrected within one
* message rather than persisting.
*/
const deltaText = new Map<string, string>();
/**
* Reasoning text per render key, for delta streaming.
*
* Delta mode strips `message.content`, and reasoning lives ONLY in that
* content — so without a parallel buffer, `thinking_delta` frames had nothing
* to paint and a 40-second reasoning phase rendered as a single lump at
* `message_end`. The two features shipped together and cancelled each other
* out. Reset from the authoritative snapshot exactly like `deltaText`.
*/
const deltaThinking = new Map<string, string>();
/**
* What is QUEUED for the running turn, from Pi's `queue_update`.
*
* Pi emits this whenever the queue changes, Brigade forwards it, the
* protocol declares it — and the TUI dropped it on the floor. The result:
* each message printed a one-line "queued" confirmation as it went in, and
* then nothing. Send three, look away, come back, and there was no way to
* answer "what is still waiting?" short of remembering.
*
* That matters most in exactly the situation the queue exists for: a long
* turn you have stacked work behind. `/flush` acts on this queue, so an
* operator deciding whether to flush needs to see it first.
*/
let queuedSteering: readonly string[] = [];
let queuedFollowUp: readonly string[] = [];
/** The last completed assistant reply, for `/copy`. Plain text, unstyled. */
let lastReplyText = "";
/**
* Live tail panes for running shell-like tools, keyed by tool call id.
*
* One widget per running command, updated in place, retired when the command
* finishes — so a chatty build does not splice a new component per update.
*/
const liveOutputPanes = new Map<string, Text>();
const pendingToolArgs = new Map<number, string>();
const pendingToolArgRows = new Map<number, Text>();
/** Paint or update the provisional "…writing call" row for one content block. */
const renderPendingToolArgs = (contentIndex: number): void => {
const raw = pendingToolArgs.get(contentIndex) ?? "";
// Partial JSON is not parseable mid-stream, so show it verbatim —
// collapsed to one line, scrubbed, and truncated. `formatToolArgs` needs a
// complete object and would return nothing useful here.
const flat = scrubRenderable(raw).replace(/\s+/g, " ").trim();
const shown = flat.length > 72 ? `${flat.slice(0, 71)}…` : flat;
const text = ` ${brand.tool("⚡")} ${brand.dim(shown ? `…${shown}` : "…preparing call")}`;
const existing = pendingToolArgRows.get(contentIndex);
if (existing) {
existing.setText(text);
} else {
const row = new Text(text, 0, 0);
pendingToolArgRows.set(contentIndex, row);
insertBeforeEditor(row);
}
tui.requestRender();
};
/** Retire the provisional row — the real `⚡` chip takes over from here. */
const clearPendingToolArgs = (contentIndex: number): void => {
const row = pendingToolArgRows.get(contentIndex);
if (row) {
try {
removeChild(row);
} catch {
/* already detached by a transcript rebuild */
}
pendingToolArgRows.delete(contentIndex);
}
pendingToolArgs.delete(contentIndex);
};
/** Drop every provisional row (turn ended, aborted, or transcript rebuilt). */
const clearAllPendingToolArgs = (): void => {
for (const idx of [...pendingToolArgRows.keys()]) clearPendingToolArgs(idx);
};
const toolCallInfo = new Map<string, { name: string; args?: string; result?: string; isError?: boolean }>();
const TOOL_INFO_MAX = 200;
/**
* Byte budget per retained tool result.
*
* The entry COUNT was capped and the bytes were not — a `read` of a large
* file or a chatty `bash` put megabytes into a single entry, so 200 entries
* could hold hundreds of MB in the TUI process. `/expand` exists to show what
* the 120-char preview cut, not to be a file viewer, so a generous per-entry
* cap costs nothing real.
*/
const TOOL_RESULT_MAX_CHARS = 64 * 1024;
const rememberToolInfo = (id: string, patch: { name?: string; args?: string; result?: string; isError?: boolean }): void => {
const prev = toolCallInfo.get(id);
if (prev) toolCallInfo.delete(id); // keep insertion order = least-recently-used
const capped =
typeof patch.result === "string" && patch.result.length > TOOL_RESULT_MAX_CHARS
? {
...patch,
result: `${patch.result.slice(0, TOOL_RESULT_MAX_CHARS)}\n… (truncated — ${patch.result.length.toLocaleString()} chars total)`,
}
: patch;
toolCallInfo.set(id, { name: patch.name ?? prev?.name ?? "tool", ...prev, ...capped });
while (toolCallInfo.size > TOOL_INFO_MAX) {
const oldest = toolCallInfo.keys().next().value as string | undefined;
if (oldest === undefined) break;
toolCallInfo.delete(oldest);
}
};
// HARNESS backends (claude-cli) stream ONE assistant message for the whole turn:
// the external binary emits text, calls a tool, then keeps writing into the SAME
// message. So `asstKey` — which identity-keys a block by `<depth>:<timestamp>` —
// resolves the post-tool continuation back to the block that was opened BEFORE the
// tool. The chip stays pinned below while new prose grows above it, which reads as
// a frozen UI. (A loop backend is unaffected: Pi genuinely starts a new message
// after each tool result, so its continuation gets a new timestamp and a new key.)
//
// So when a tool fires we remember how much of that message's text was already on
// screen; every later update for the same key renders only the text BEYOND that
// mark, into a fresh block below the chip. `message_end`, which carries the whole
// message, lands as the same tail — never a duplicate of the pre-tool prose.
const asstTextLen = new Map<string, number>();
const asstKeyByDepth = new Map<number, string>();
const asstContinuation = new Map<string, { prefixLen: number; block?: Markdown }>();
/** Turn boundary: these track ONE turn's message identities, like `activeAssistants`. */
const clearHarnessContinuations = (): void => {
asstTextLen.clear();
asstKeyByDepth.clear();
asstContinuation.clear();
};
/**
* Why turning reasoning ON may still show you nothing.
*
* ─────────────────────────────────────────────────────────────────────────
* THE CONFUSION THIS REMOVES
* ─────────────────────────────────────────────────────────────────────────
* `reasoning: on` describes a RENDERER preference — "show me reasoning text
* if any arrives". It says nothing about whether any will. Three ordinary
* situations produce an empty result, and the toggle looked identical in all
* of them:
*
* • the model does not reason at all (most fast/flash models);
* • the model reasons, but this thread's effort is `off`;
* • the model reasons and the provider does not expose the text
* (Anthropic's current default: billed, and omitted).
*
* An operator who flips the toggle and sees nothing reasonably concludes the
* toggle is broken. It is not — there is nothing to show, and the honest
* thing is to say which of the three it is at the moment they ask.
*
* Returns "" when reasoning genuinely is expected, so the common case stays
* a clean one-liner.
*/
const reasoningAvailabilityNote = (): string => {
const snap = lastSnapshot;
if (!snap) return "";
if (snap.supportsThinking !== true) {
return " — this model does not expose reasoning, so nothing will appear";
}
if (typeof snap.thinkingLevel === "string" && snap.thinkingLevel === "off") {
return " — thinking is off for this thread; set a level with /thinking";
}
const vis = snap.reasoning?.visibility;
if (vis === "hidden" || vis === "redacted") {
return ` — ${describeReasoningVisibility(vis)}`;
}
return "";
};
/**
* Everything that must forget the old thread when the context changes.
*
* ─────────────────────────────────────────────────────────────────────────
* WHY THIS IS ONE FUNCTION
* ─────────────────────────────────────────────────────────────────────────
* Four commands switch context — `/new`, `/session`, `/agent`, `/delete` —
* and each maintained its own hand-written sequence. They diverged, exactly
* as that arrangement guarantees: one cleared the transcript, another zeroed
* the totals, a third did neither, and a whole set of per-thread values was
* cleared by NONE of them.
*
* The ones nothing reset are the dangerous half, because they are read by
* commands that then act on the wrong thread:
*
* • `lastUserPrompt` / `lastUserAttachments` — `/switch` replays these, so
* it re-sent the PREVIOUS thread's message, with its attachments, into
* the new one. That does not merely mislead; it acts.
* • `toolCallInfo` — `/expand` with no argument is documented as "the
* result you just saw", and printed one from a thread whose transcript
* had been wiped off the screen.
* • `lastReplyText` — `/copy` copied the previous thread's reply and
* confirmed with a byte count, so it looked like it worked.
* • the queue chip and the running/elapsed state — the old lane's
* `agent_end` is dropped by the off-lane guard, so a `●` dot and a
* climbing timer sat over an idle thread while `/abort` insisted a turn
* was running and `/update` refused to run because of it.
*
* Adding a per-thread value now means adding it HERE, once, instead of
* remembering four call sites.
*/
const switchContext = (what: string, opts: { zeroTotals?: boolean } = {}): void => {
clearTrayForContextSwitch(what);
// The collapsed-notice widget lives in the region about to be cleared.
lastSystemEventLine = undefined;
// Replay + "last thing" state. All of these answer "in this thread",
// and after a switch this thread has no answer yet.
lastUserPrompt = "";
lastUserAttachments = [];
lastReplyText = "";
toolCallInfo.clear();
// Turn state belongs to the lane we just left. Its terminal event will be
// filtered out as off-lane, so nothing else will ever clear these.
queuedSteering = [];
queuedFollowUp = [];
isAgentRunning = false;
agentStartedAt = null;
// Usage figures are per (agent, sessionKey) at the gateway, so a fresh
// binding WILL report zero — but only after `subscribe` round-trips.
// Zeroing here stops the previous thread's totals sitting under the new
// thread's banner in the meantime, which reads as context carrying over.
if (opts.zeroTotals !== false && lastSnapshot) {
lastSnapshot = {
...lastSnapshot,
totalTokensIn: 0,
totalTokensOut: 0,
totalCostUsd: 0,
contextUsagePercent: null,
contextTokens: null,
messageCount: 0,
reasoning: undefined,
} as typeof lastSnapshot;
}
};
/** Take the "thinking" spinner down. Called only when a paint is about to land. */
const dismissLoader = (): void => {
if (!activeLoader) return;
removeChild(activeLoader);
activeLoader = null;
};
// A newer Brigade is published. Say so ONCE per attach — every state mutation
// pushes a snapshot, and a notice that reprints on each one is an advert.
//
// It asks; it never acts. An update restarts the gateway, and the operator may be
// mid-turn. It also answers the question they will actually have — "will this eat
// my work?" — before they have to ask it.
let announcedUpdate = false;
const maybeAnnounceUpdate = (upd: { current: string; latest: string } | null): void => {
if (!upd || announcedUpdate) return;
announcedUpdate = true;
insertBeforeEditor(
new Text(
` ${brand.amber("↑")} ${brand.dim(`Brigade ${upd.latest} is available — you're on ${upd.current}.`)}\n` +
` ${brand.dim(UPDATE_PRESERVES_MESSAGE)}\n` +
` ${brand.dim("Update now?")} ${chalk.bold("/update")} ${brand.dim("· or later, from any shell:")} ${chalk.bold("brigade update")}`,
0,
0,
),
);
tui.requestRender();
};
// `asstKey` (identity key for an assistant block) is imported from
// `connect-transcript.js` so the live path + the resume rebuild + the unit
// tests all share one definition.
// Streaming render coalescer. Every `setText()` on the streaming Markdown
// widget invalidates the parser cache, so each paint re-parses the FULL
// growing reply (Marked + line-wrap + ANSI styling). At 60Hz that's a
// continuous parse stall that blocks scroll events queued in the
// terminal's input buffer, causing the flicker + scroll-lock the operator
// sees on Windows Terminal. 150ms (~6–7 paints/sec while streaming) is the
// chosen default — slow enough that each paint's parse cost is small
// relative to the gap, fast enough that streaming still feels live.
// Override with `BRIGADE_STREAM_RENDER_MS` (clamped to ≥16). Raise to 250–400
// if a slow terminal still flickers; lower to 30–60 on a fast terminal
// (iTerm, Alacritty, recent Kitty) for snappier streaming.
const streamRenderMs = Math.max(
16,
Number(process.env.BRIGADE_STREAM_RENDER_MS) || 150,
);
let streamRenderTimer: NodeJS.Timeout | null = null;
const scheduleStreamingRender = (): void => {
if (streamRenderTimer) return;
streamRenderTimer = setTimeout(() => {
streamRenderTimer = null;
tui.requestRender();
}, streamRenderMs);
if (typeof streamRenderTimer.unref === "function") streamRenderTimer.unref();
};
const flushStreamingRender = (): void => {
if (streamRenderTimer) {
clearTimeout(streamRenderTimer);
streamRenderTimer = null;
}
tui.requestRender();
};
// Elapsed-time tracker for the running agent. Started on `agent_start`,
// cleared on `agent_end`. Read by the 1s ticker below to refresh the
// header so the user can see "thinking… 12s" / "thinking… 1m 4s" instead
// of a static "thinking…". Brigade-shape (no phrase rotation, just
// clean numbers).
let agentStartedAt: number | null = null;
// Whimsical phrase rotator for the loader. Rotates in the header tail
// every 4s while the agent is busy. Pi-TUI's CancellableLoader doesn't
// expose a label setter so we pipe the phrase into the header `extra`
// slot instead.
const WHIMSICAL_PHRASES = [
"thinking",
"flibbertigibbeting",
"kerfuffling",
"dillydallying",
"twiddling thumbs",
"noodling",
"bamboozling",
"moseying",
"hobnobbing",
"pondering",
"conjuring",
];
let whimsicalIdx = 0;
// Thinking-block visibility. Defaults ON.
//
// It defaulted OFF, which meant most operators never learned reasoning
// existed — and because the toggle is a plain local, anyone who did find
// `/reasoning on` had to retype it every launch. Every comparable harness
// surveyed shows reasoning by default (Cline, Roo, Continue, Aider, Grok
// Build, Gemini CLI); the one that hides it, Claude Code, does so because
// its CLI actively asks the API to redact it.
//
// Brigade now requests thinking summaries from the claude-cli backend (see
// `buildClaudeCliArgs`), so on the most common install there is finally
// something to show. The header carries the one-line state regardless of
// this flag — this controls only whether the reasoning TEXT is rendered