Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/cli/src/ui/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ import { handleCtrlC, handleCtrlD } from "./runtime/interrupt.ts";
import { PastedImageRegistry, resolvePastedImages } from "./runtime/pasted-images.ts";
import { createRedraw, type Redraw } from "./runtime/redraw.ts";
import { handleSessionEvent, subscribeToAgent } from "./runtime/session-events.ts";
import {
keepLatestTaskUpdateResult,
registerTaskUpdateCall,
resetTaskUpdateTranscript,
} from "./runtime/task-update-transcript.ts";
import { FooterComponent, formatTokens } from "./view/chrome/footer.ts";
import {
BranchSummaryStatusIndicator,
Expand Down Expand Up @@ -3582,6 +3587,7 @@ export class InteractiveMode {
items: readonly RenderSessionItem[],
options: { updateFooter?: boolean; populateHistory?: boolean } = {},
): void {
resetTaskUpdateTranscript(this.chatContainer);
this.pendingTools.clear();
const renderedPendingTools = new Map<string, ToolExecutionComponent>();
// Cache-miss notices are not persisted; re-derive them from the full entry
Expand Down Expand Up @@ -3628,6 +3634,9 @@ export class InteractiveMode {
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
if (content.name === "task_update") {
registerTaskUpdateCall(this.chatContainer, content.id);
}

if (message.stopReason === "aborted" || message.stopReason === "error") {
let errorMessage: string;
Expand All @@ -3644,6 +3653,7 @@ export class InteractiveMode {
content: [{ type: "text", text: errorMessage }],
isError: true,
});
keepLatestTaskUpdateResult(this.chatContainer, content.id, component, true);
} else {
renderedPendingTools.set(content.id, component);
}
Expand All @@ -3658,6 +3668,7 @@ export class InteractiveMode {
const component = renderedPendingTools.get(message.toolCallId);
if (component) {
component.updateResult(message);
keepLatestTaskUpdateResult(this.chatContainer, message.toolCallId, component, message.isError);
renderedPendingTools.delete(message.toolCallId);
}
} else {
Expand Down
13 changes: 12 additions & 1 deletion apps/cli/src/ui/runtime/session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
WorkingStatusIndicator,
} from "../view/index.ts";
import type { RuntimeContext } from "./context.ts";
import { keepLatestTaskUpdateResult, registerTaskUpdateCall } from "./task-update-transcript.ts";

/** Subscribe to the session's agent events. Returns/records the unsubscribe handle (owned by the composition root). */
export function subscribeToAgent(ctx: RuntimeContext): void {
Expand Down Expand Up @@ -171,6 +172,9 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio
);
component.setExpanded(ctx.toolOutputExpanded);
ctx.chatContainer.addChild(component);
if (content.name === "task_update") {
registerTaskUpdateCall(ctx.chatContainer, content.id);
}
ctx.pendingTools.set(content.id, component);
} else {
const component = ctx.pendingTools.get(content.id);
Expand Down Expand Up @@ -209,11 +213,12 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio
if (!errorMessage) {
errorMessage = ctx.streamingMessage.errorMessage || "Error";
}
for (const [, component] of ctx.pendingTools.entries()) {
for (const [toolCallId, component] of ctx.pendingTools.entries()) {
component.updateResult({
content: [{ type: "text", text: errorMessage }],
isError: true,
});
keepLatestTaskUpdateResult(ctx.chatContainer, toolCallId, component, true);
}
for (const toolCallId of ctx.pendingTools.keys()) {
ctx.stepSpinner?.stop(toolCallId);
Expand Down Expand Up @@ -256,7 +261,12 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio
);
component.setExpanded(ctx.toolOutputExpanded);
ctx.chatContainer.addChild(component);
if (event.toolName === "task_update") {
registerTaskUpdateCall(ctx.chatContainer, event.toolCallId);
}
ctx.pendingTools.set(event.toolCallId, component);
} else if (event.toolName === "task_update") {
registerTaskUpdateCall(ctx.chatContainer, event.toolCallId);
}
component.markExecutionStarted();
ctx.stepSpinner?.start(event.toolCallId, event.toolName);
Expand Down Expand Up @@ -289,6 +299,7 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio
const component = ctx.pendingTools.get(event.toolCallId);
if (component) {
component.updateResult({ ...event.result, isError: event.isError });
keepLatestTaskUpdateResult(ctx.chatContainer, event.toolCallId, component, event.isError);
ctx.pendingTools.delete(event.toolCallId);
ctx.stepSpinner?.stop(event.toolCallId);
// 工具结束不立刻降级动词:瞬时工具(read ~300ms)的动词如果
Expand Down
58 changes: 58 additions & 0 deletions apps/cli/src/ui/runtime/task-update-transcript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Component, Container } from "@step-harness/pi-tui";

interface TaskUpdateTranscriptState {
nextOrdinal: number;
callOrdinals: Map<string, number>;
latestSuccessful?: {
ordinal: number;
component: Component;
};
}

const transcriptStates = new WeakMap<Container, TaskUpdateTranscriptState>();

export function resetTaskUpdateTranscript(container: Container): void {
transcriptStates.set(container, {
nextOrdinal: 0,
callOrdinals: new Map(),
});
}

export function registerTaskUpdateCall(container: Container, toolCallId: string): void {
const state = getState(container);
if (state.callOrdinals.has(toolCallId)) return;
state.nextOrdinal += 1;
state.callOrdinals.set(toolCallId, state.nextOrdinal);
}

export function keepLatestTaskUpdateResult(
container: Container,
toolCallId: string,
component: Component,
isError: boolean,
): void {
const state = getState(container);
const ordinal = state.callOrdinals.get(toolCallId);
if (ordinal === undefined) return;
state.callOrdinals.delete(toolCallId);
if (isError) return;

if (state.latestSuccessful && ordinal < state.latestSuccessful.ordinal) {
container.removeChild(component);
return;
}

if (state.latestSuccessful && state.latestSuccessful.component !== component) {
container.removeChild(state.latestSuccessful.component);
}
state.latestSuccessful = { ordinal, component };
}

function getState(container: Container): TaskUpdateTranscriptState {
let state = transcriptStates.get(container);
if (!state) {
resetTaskUpdateTranscript(container);
state = transcriptStates.get(container)!;
}
return state;
}
6 changes: 4 additions & 2 deletions apps/cli/test/step-task-plan.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@step-harness/coding-agent";
import { stripTerminalSequences, type TUI, visibleWidth } from "@step-harness/pi-tui";
import { Container, stripTerminalSequences, type TUI, visibleWidth } from "@step-harness/pi-tui";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStepTasksExtension } from "../../../packages/coding-agent/src/features/step-tasks.ts";
import { initTheme, theme } from "../../../packages/coding-agent/src/theme/theme.ts";
Expand Down Expand Up @@ -88,6 +88,7 @@ describe("inline task plan", () => {
await execute("task_create", { subject: `Task ${index}`, description: "work" });
}
const pendingTools = new Map<string, ToolExecutionComponent>();
const chatContainer = new Container();
const runtime = {
isInitialized: true,
footer: { invalidate: vi.fn() },
Expand All @@ -97,7 +98,7 @@ describe("inline task plan", () => {
getRegisteredToolDefinition: (name: string) => tools.get(name),
ui,
sessionManager: { getCwd: () => "/tmp" },
chatContainer: { addChild: vi.fn() },
chatContainer,
workingOutputTracker: { notifyToolStarted: vi.fn() },
workingVisible: false,
redraw: { requestRender: vi.fn() },
Expand Down Expand Up @@ -140,6 +141,7 @@ describe("inline task plan", () => {
});
expect(first.render(80).map(stripTerminalSequences).join("\n")).toContain("Updated Plan (1/5)");
expect(second.render(80)).toEqual(latestRows);
expect(chatContainer.children).toEqual([second]);
expect(pendingTools.size).toBe(0);
expect(runtime.redraw.requestRender).toHaveBeenCalledTimes(4);
});
Expand Down
49 changes: 49 additions & 0 deletions apps/cli/test/task-update-transcript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Container, Text } from "@step-harness/pi-tui";
import { describe, expect, it } from "vitest";
import {
keepLatestTaskUpdateResult,
registerTaskUpdateCall,
resetTaskUpdateTranscript,
} from "../src/ui/runtime/task-update-transcript.ts";

function add(container: Container, label: string): Text {
const component = new Text(label, 0, 0);
container.addChild(component);
return component;
}

describe("task update transcript projection", () => {
// Regression for GitHub issue #194.
it("keeps later pending updates mounted during session replay", () => {
const container = new Container();
const first = add(container, "first");
const second = add(container, "second");
resetTaskUpdateTranscript(container);
registerTaskUpdateCall(container, "first");
registerTaskUpdateCall(container, "second");

keepLatestTaskUpdateResult(container, "first", first, false);
expect(container.children).toEqual([first, second]);

keepLatestTaskUpdateResult(container, "second", second, false);
expect(container.children).toEqual([second]);
});

// Regression for GitHub issue #194.
it("keeps failed updates visible when a later update succeeds", () => {
const container = new Container();
const first = add(container, "first");
const failed = add(container, "failed");
const latest = add(container, "latest");
resetTaskUpdateTranscript(container);
registerTaskUpdateCall(container, "first");
registerTaskUpdateCall(container, "failed");
registerTaskUpdateCall(container, "latest");

keepLatestTaskUpdateResult(container, "first", first, false);
keepLatestTaskUpdateResult(container, "failed", failed, true);
keepLatestTaskUpdateResult(container, "latest", latest, false);

expect(container.children).toEqual([failed, latest]);
});
});
5 changes: 4 additions & 1 deletion packages/tui/src/components/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,10 @@ export class Editor implements Component, Focusable {
if (kb.matches(data, "tui.editor.cursorUp")) {
if (
this.isOnFirstVisualLine() &&
(this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0)
(this.isEditorEmpty() ||
this.historyIndex > -1 ||
this.state.lines.length === 1 ||
this.state.cursorCol === 0)
) {
this.navigateHistory(-1);
} else if (this.isOnFirstVisualLine()) {
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export const TUI_KEYBINDINGS = {
description: "Close transcript search",
},
"tui.altScreen.top": { defaultKeys: "home", description: "Scroll viewport to top" },
"tui.altScreen.bottom": { defaultKeys: "end", description: "Scroll viewport to bottom" },
"tui.altScreen.bottom": { defaultKeys: ["end", "ctrl+end"], description: "Scroll viewport to bottom" },
} as const satisfies KeybindingDefinitions;

export interface KeybindingConflict {
Expand Down
32 changes: 18 additions & 14 deletions packages/tui/test/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ describe("Editor component", () => {
assert.strictEqual(editor.getText(), "second prompt");
});

it("recalls history immediately from a single-line draft", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);

editor.addToHistory("previous prompt");
editor.setText("draft");

editor.handleInput("\x1b[A");

assert.strictEqual(editor.getText(), "previous prompt");
editor.handleInput("\x1b[B");
assert.strictEqual(editor.getText(), "draft");
});

it("cycles through history entries on repeated Up arrow", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);

Expand All @@ -80,24 +93,15 @@ describe("Editor component", () => {
assert.strictEqual(editor.getText(), "first");
});

it("jumps to start before entering history from a non-empty draft", () => {
it("uses cursor movement instead of history within a multiline draft", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);

editor.addToHistory("prompt");
editor.setText("draft");
editor.handleInput("\x1b[D");
editor.handleInput("\x1b[D");

editor.handleInput("\x1b[A"); // Up - jumps to start before history browsing
assert.strictEqual(editor.getText(), "draft");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 });

editor.handleInput("\x1b[A"); // Up at start - shows "prompt"
assert.strictEqual(editor.getText(), "prompt");
editor.setText("line1\nline2");

editor.handleInput("\x1b[B"); // Down - restores draft
assert.strictEqual(editor.getText(), "draft");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 });
editor.handleInput("\x1b[A");
assert.strictEqual(editor.getText(), "line1\nline2");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 });
});

it("navigates forward through history with Down arrow", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/test/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe("KeybindingsManager", () => {
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchPrevious"), ["shift+enter", "ctrl+shift+g"]);
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchClose"), ["escape"]);
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["home"]);
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end"]);
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end", "ctrl+end"]);
});

it("does not evict selector confirm when input submit is rebound", () => {
Expand Down
21 changes: 17 additions & 4 deletions packages/tui/test/tui-alt-screen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,20 @@ describe("TuiAltScreen", () => {
assert.strictEqual(tui.viewportTop, 5);
assert.strictEqual(tui.isFollowingOutput, false);

terminal.sendInput("\x1b[1;5F");
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 7", "line 8", "line 9", "line 10"],
);
assert.strictEqual(tui.isFollowingOutput, true);

text.setText(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"));
tui.requestRender();
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 6", "line 7", "line 8", "line 9"],
["line 9", "line 10", "line 11", "line 12"],
);

tui.stop();
Expand Down Expand Up @@ -561,7 +569,7 @@ describe("TuiAltScreen", () => {
}
});

it("routes Ctrl-modified viewport navigation to the focused component", async () => {
it("routes Ctrl-modified viewport navigation to the focused component except Ctrl+End", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TuiAltScreen(terminal);
const transcript = new ScrollView(
Expand Down Expand Up @@ -590,16 +598,21 @@ describe("TuiAltScreen", () => {
assert.strictEqual(transcript.scrollTop, 0);
assert.deepStrictEqual(editorInputs, []);

const modifiedInputs = ["\x1b[1;5H", "\x1b[1;5F", "\x1b[5;5~", "\x1b[6;5~", "\x1b[57423;5u"];
const modifiedInputs = ["\x1b[1;5H", "\x1b[5;5~", "\x1b[6;5~", "\x1b[57423;5u"];
for (const input of modifiedInputs) terminal.sendInput(input);
terminal.sendInput("\x1b[57423;5:3u");
await terminal.waitForRender();
assert.strictEqual(transcript.scrollTop, 0);
assert.deepStrictEqual(editorInputs, modifiedInputs);

terminal.sendInput("\x1b[1;5F");
await terminal.waitForRender();
assert.strictEqual(transcript.scrollTop, 7);
assert.deepStrictEqual(editorInputs, modifiedInputs);

terminal.sendInput("\x1b[6~");
await terminal.waitForRender();
assert.strictEqual(transcript.scrollTop, 1);
assert.strictEqual(transcript.scrollTop, 7);
assert.deepStrictEqual(editorInputs, modifiedInputs);

tui.stop();
Expand Down
Loading