Skip to content

Commit f65cde1

Browse files
committed
Fix gutter failure dim, runner cycle, and stale row updates
1 parent 15d2b68 commit f65cde1

8 files changed

Lines changed: 121 additions & 37 deletions

File tree

‎scripts/budgets.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,5 +272,7 @@
272272
"src/tui/width-contract.ts": 71,
273273
"src/tui/workflow-controller.ts": 271,
274274
"src/tui/workspace-watch.test.ts": 101,
275-
"src/tui/workspace-watch.ts": 62
275+
"src/tui/workspace-watch.ts": 62,
276+
"src/tui/row-retext.test.ts": 76,
277+
"src/tui/runner/send-failure-message.ts": 38
276278
}

‎src/tui/row-retext.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* In-place retext keeps a row's paint node when only its state flips — most
3+
* importantly the gutter voice: a tool row that fails after being painted
4+
* pending must dim its gutter on the same node, not keep the live bronze.
5+
*/
6+
import { describe, expect, test } from "bun:test";
7+
import {
8+
BoxRenderable,
9+
TextRenderable,
10+
parseColor,
11+
rgbToHex,
12+
type ColorInput,
13+
} from "@opentui/core";
14+
import { withTestRenderer } from "./harness";
15+
import { appendStreamRow, replaceStreamRowAt } from "./shell/chrome";
16+
import { createAppShell } from "./shell/index";
17+
import { transcriptRowChildren } from "./shell/transcript";
18+
import { UI } from "./theme";
19+
import { pushToolCall, pushToolResult } from "./tool-rows";
20+
import type { StreamRow } from "./stream";
21+
22+
const SHELL_OPTS = {
23+
terminal: { columns: 100, rows: 24 },
24+
wireKeys: false,
25+
run: "idle",
26+
} as const;
27+
28+
const gutterOf = (node: unknown): TextRenderable => {
29+
if (!(node instanceof BoxRenderable)) throw new Error("row node is not a wrapper");
30+
const [gutter] = node.getChildren();
31+
if (!(gutter instanceof TextRenderable)) throw new Error("first child is not the gutter");
32+
return gutter;
33+
};
34+
35+
/** The node normalizes fg to an RGBA; compare it to the hex token exactly. */
36+
const fgIs = (gutter: TextRenderable, hex: string): boolean =>
37+
rgbToHex(parseColor(gutter.fg as ColorInput)) === hex;
38+
39+
describe("retext gutter voice", () => {
40+
test("a tool row that fails after being painted pending dims its gutter in place", async () => {
41+
await withTestRenderer(
42+
async (h) => {
43+
const shell = createAppShell(h.renderer, SHELL_OPTS);
44+
try {
45+
const rows: StreamRow[] = [];
46+
pushToolCall(rows, {
47+
name: "fetch",
48+
arguments: JSON.stringify({ url: "https://x.dev" }),
49+
});
50+
const pending = rows[0]!;
51+
expect(pending.pending).toBe(true);
52+
appendStreamRow(shell, pending);
53+
await h.renderOnce();
54+
const wrapper = transcriptRowChildren(shell)[0];
55+
const gutter = gutterOf(wrapper);
56+
const fgToken = `${gutter.fg}`;
57+
expect(fgToken).not.toBe(UI.textDim);
58+
expect(fgIs(gutter, UI.textDim)).toBe(false);
59+
60+
pushToolResult(rows, { name: "fetch", content: "", isError: true });
61+
const failed = rows[0]!;
62+
expect(failed.failed).toBe(true);
63+
replaceStreamRowAt(shell, 0, failed);
64+
// Same paint node, same shape: the flip retexted rather than rebuilt.
65+
expect(transcriptRowChildren(shell)[0]).toBe(wrapper);
66+
expect(fgIs(gutter, UI.textDim)).toBe(true);
67+
await h.renderOnce();
68+
} finally {
69+
shell.dispose();
70+
}
71+
},
72+
{ width: 100, height: 24 },
73+
);
74+
});
75+
});

‎src/tui/runner/commands.ts‎

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/**
22
* Command layer for the TUI runner: slash-command registry population, the
3-
* command context the handlers run against, result surfacing, and the
4-
* inference-failure message contract shared by the submit path.
3+
* command context the handlers run against, and result surfacing.
54
*/
65

76
import { getLogger } from "@intx/log";
@@ -34,43 +33,14 @@ import { renameSession } from "../../session/index.js";
3433
import { truncateSessionLabel } from "../../session/session-label.js";
3534
import { surfaceSystemNotice, attachClipboardImage } from "../shell/prompt.js";
3635
import type { InferenceErrorLike } from "../../inference-gateway-error.js";
37-
import {
38-
CREDENTIAL_FAILURE_USER_MESSAGE,
39-
isResolvedProviderFailureError,
40-
terminalProviderFailureMessage,
41-
} from "../../inference-error-message.js";
36+
import { terminalProviderFailureMessage } from "../../inference-error-message.js";
4237
import type { InferenceAttemptIdentity } from "./state.js";
4338
import { hostOf, type RunnerServices, type RunnerState } from "./state.js";
4439
import { userInboundMessage } from "./submit.js";
4540
import { LOG_NAMESPACE_ROOT } from "../../branding.js";
4641

4742
const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]);
4843

49-
export function tuiSendFailureMessage(
50-
error: unknown,
51-
failureKind: "auth" | "error",
52-
providerFailureObserved: boolean,
53-
attempt: InferenceAttemptIdentity,
54-
providerError?: InferenceErrorLike,
55-
): string {
56-
if (failureKind === "auth") {
57-
return CREDENTIAL_FAILURE_USER_MESSAGE;
58-
}
59-
if (!providerFailureObserved && !isResolvedProviderFailureError(error)) {
60-
return error instanceof Error ? error.message : String(error);
61-
}
62-
const providerId =
63-
providerError?.providerId ??
64-
(isResolvedProviderFailureError(error) ? error.providerId : attempt.providerId);
65-
const displayLabel = providerId === attempt.providerId ? attempt.displayLabel : undefined;
66-
if (providerError === undefined && isResolvedProviderFailureError(error)) return error.message;
67-
const diagnostic = providerError ?? {
68-
category: "fatal",
69-
message: error instanceof Error ? error.message : String(error),
70-
};
71-
return terminalProviderFailureMessage(providerId, diagnostic, displayLabel);
72-
}
73-
7444
export function surfaceTerminalProviderFailure(
7545
shell: Parameters<typeof surfaceSystemNotice>[0],
7646
providerId: string,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* The operator-facing message for a rejected send (the inference-failure
3+
* contract the submit path reports through). Lives in its own leaf so the
4+
* submit and commands siblings never import each other.
5+
*/
6+
import type { InferenceErrorLike } from "../../inference-gateway-error.js";
7+
import {
8+
CREDENTIAL_FAILURE_USER_MESSAGE,
9+
isResolvedProviderFailureError,
10+
terminalProviderFailureMessage,
11+
} from "../../inference-error-message.js";
12+
import type { InferenceAttemptIdentity } from "./state.js";
13+
14+
export function tuiSendFailureMessage(
15+
error: unknown,
16+
failureKind: "auth" | "error",
17+
providerFailureObserved: boolean,
18+
attempt: InferenceAttemptIdentity,
19+
providerError?: InferenceErrorLike,
20+
): string {
21+
if (failureKind === "auth") {
22+
return CREDENTIAL_FAILURE_USER_MESSAGE;
23+
}
24+
if (!providerFailureObserved && !isResolvedProviderFailureError(error)) {
25+
return error instanceof Error ? error.message : String(error);
26+
}
27+
const providerId =
28+
providerError?.providerId ??
29+
(isResolvedProviderFailureError(error) ? error.providerId : attempt.providerId);
30+
const displayLabel = providerId === attempt.providerId ? attempt.displayLabel : undefined;
31+
if (providerError === undefined && isResolvedProviderFailureError(error)) return error.message;
32+
const diagnostic = providerError ?? {
33+
category: "fatal",
34+
message: error instanceof Error ? error.message : String(error),
35+
};
36+
return terminalProviderFailureMessage(providerId, diagnostic, displayLabel);
37+
}

‎src/tui/runner/submit.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
routeQueuedDelivery,
3535
} from "../queued-delivery.js";
3636
import type { InferenceAttemptIdentity } from "./state.js";
37-
import { tuiSendFailureMessage } from "./commands.js";
37+
import { tuiSendFailureMessage } from "./send-failure-message.js";
3838
import type { ProviderFailureAttempt } from "../provider/failure-attempt.js";
3939
import type { Agent } from "@intx/agent";
4040
import { hostOf, type RunnerServices, type RunnerState } from "./state.js";

‎src/tui/runtime-bridge.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1328,12 +1328,12 @@ export function attachSessionBridge(
13281328
bag.turn = turnStateOnInterrupt(bag.turn, now());
13291329
paintPhase();
13301330
};
1331-
13321331
const clearQueuedDelivery = (): void => {
13331332
if (bag.disposed) return;
13341333
shell.session = createSessionQueue("idle");
13351334
bag.pendingEchoes.length = 0;
13361335
bag.liveFleet = 0;
1336+
bag.pendingRowUpdates.clear();
13371337
paintChrome(shell);
13381338
};
13391339

‎src/tui/shell/row-retext.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ export function retextStyledKindRow(
7272
return false;
7373
}
7474

75-
/** Rewrite the gutter a styled-lines or structured wrapper carries. */
7675
function retextGutter(node: TextRenderable, gutter: PaintedStreamLine): void {
7776
node.content = gutter.content;
77+
node.fg = gutter.fg;
7878
node.width = stringWidth(gutter.content);
7979
}
8080

‎tests/unit/tui/runner.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from "../../../src/tui/runner/exit.js";
99
import { createTUIEventEmitter, getTUIRunSummaryStatus } from "../../../src/tui/runner/index.js";
1010
import { loadLocalSettingsWriteBase } from "../../../src/tui/runner/settings.js";
11-
import { tuiSendFailureMessage } from "../../../src/tui/runner/commands.js";
11+
import { tuiSendFailureMessage } from "../../../src/tui/runner/send-failure-message.js";
1212
import { createSessionOperationQueue } from "../../../src/tui/session-operation-queue.js";
1313
import { createRunSink } from "../../../src/session/run-sink.js";
1414

0 commit comments

Comments
 (0)