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
3 changes: 1 addition & 2 deletions extensions/shared/agent-session-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,10 @@ function state(): AgentSessionPageState {
};
}

test("writable and read-only children use one full-terminal page", () => {
test("Direct and Workflow children use one read-only full-terminal page", () => {
const direct = new AgentSessionPage(tui(18), theme, keybindings, {
getState: state,
close() {},
send() {},
});
const workflow = new AgentSessionPage(tui(18), theme, keybindings, {
getState: state,
Expand Down
43 changes: 8 additions & 35 deletions extensions/shared/agent-session-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type {
} from "@earendil-works/pi-coding-agent";
import type { Component, Focusable, TUI } from "@earendil-works/pi-tui";
import {
Input,
Key,
matchesKey,
truncateToWidth,
Expand Down Expand Up @@ -36,7 +35,6 @@ export interface AgentSessionPageState {
export interface AgentSessionPageSource {
getState(): AgentSessionPageState | undefined;
close(): void;
send?(text: string): void;
abort?(): void;
}

Expand Down Expand Up @@ -67,14 +65,13 @@ function stateGlyph(state: AgentSessionPageState, theme: Theme, now: number) {
/**
* One full-screen child-session page shared by Direct and Workflow adapters.
* The source owns lifecycle facts; this module owns only page rendering,
* reading position, optional input, and navigation back to the parent view.
* reading position, and navigation back to the parent view.
*/
export class AgentSessionPage implements Component, Focusable {
private tui: TUI;
private theme: Theme;
private keybindings: KeybindingsManager;
private source: AgentSessionPageSource;
private input = new Input();
private renderer = new AgentTranscriptRenderer();
private viewport = new TranscriptViewport();
private rowCount = 0;
Expand All @@ -86,7 +83,6 @@ export class AgentSessionPage implements Component, Focusable {
}
set focused(value: boolean) {
this._focused = value;
this.input.focused = value;
}

constructor(
Expand All @@ -99,14 +95,6 @@ export class AgentSessionPage implements Component, Focusable {
this.theme = theme;
this.keybindings = keybindings;
this.source = source;
this.input.onSubmit = (value: string) => {
const text = value.trim();
if (!text || !this.source.send) return;
this.input.setValue("");
this.source.send(text);
this.viewport.scrollToEnd(this.rowCount, this.viewportSize);
this.tui.requestRender();
};
}

handleInput(data: string) {
Expand All @@ -127,27 +115,21 @@ export class AgentSessionPage implements Component, Focusable {
return;
}

const writable = Boolean(this.source.send);
const inputEmpty = !writable || this.input.getValue().length === 0;
if (
inputEmpty &&
(this.keybindings.matches(data, "tui.editor.cursorLeft") ||
(!writable && data === "h"))
this.keybindings.matches(data, "tui.editor.cursorLeft") ||
data === "h"
) {
this.source.close();
return;
}
if (
this.keybindings.matches(data, "tui.editor.cursorUp") ||
(!writable && data === "k")
) {
if (this.keybindings.matches(data, "tui.editor.cursorUp") || data === "k") {
this.viewport.scrollBy(-SCROLL_STEP, this.rowCount, this.viewportSize);
this.tui.requestRender();
return;
}
if (
this.keybindings.matches(data, "tui.editor.cursorDown") ||
(!writable && data === "j")
data === "j"
) {
this.viewport.scrollBy(SCROLL_STEP, this.rowCount, this.viewportSize);
this.tui.requestRender();
Expand Down Expand Up @@ -177,20 +159,16 @@ export class AgentSessionPage implements Component, Focusable {
this.tui.requestRender();
return;
}
if (inputEmpty && (matchesKey(data, Key.home) || data === "g")) {
if (matchesKey(data, Key.home) || data === "g") {
this.viewport.scrollToTop(this.rowCount, this.viewportSize);
this.tui.requestRender();
return;
}
if (inputEmpty && (matchesKey(data, Key.end) || data === "G")) {
if (matchesKey(data, Key.end) || data === "G") {
this.viewport.scrollToEnd(this.rowCount, this.viewportSize);
this.tui.requestRender();
return;
}
if (writable) {
this.input.handleInput(data);
this.tui.requestRender();
}
}

private rule(width: number, left = "", right = "") {
Expand Down Expand Up @@ -245,8 +223,7 @@ export class AgentSessionPage implements Component, Focusable {
metadata.shift();
}

const writable = Boolean(this.source.send);
const chromeRows = writable ? 5 : 4;
const chromeRows = 4;
const errorRows = state.errorText ? 1 : 0;
const bodyHeight = Math.max(1, height - chromeRows);
const transcriptCapacity = Math.max(1, bodyHeight - errorRows);
Expand Down Expand Up @@ -300,12 +277,9 @@ export class AgentSessionPage implements Component, Focusable {
),
),
);
if (writable) lines.push(...this.input.render(width));

const keys = (binding: Parameters<KeybindingsManager["getKeys"]>[0]) =>
configuredKeys(this.keybindings, binding);
const hints: ScreenHint[] = [];
if (writable) hints.push([keys("tui.input.submit"), "send"]);
hints.push([keys("app.interrupt"), "back"]);
if (this.source.abort) hints.push([keys("app.clear"), "abort run"]);
hints.push(
Expand All @@ -322,7 +296,6 @@ export class AgentSessionPage implements Component, Focusable {
}

invalidate() {
this.input.invalidate();
this.renderer.invalidate();
}
}
5 changes: 2 additions & 3 deletions extensions/subagents/src/ui/takeover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* Takeover UI for subagents (ported from v1, rendering from the synchronous
* SubagentReadModel instead of live pi sessions):
* - SubagentDashboard: compact picker docked above the input, listing all subagents.
* - TakeoverView: full interactive view of one subagent with an input line
* to steer/continue it.
* - TakeoverView: full read-only view of one subagent; steering remains owned
* by the parent model through the subagent tools.
*/

import type {
Expand Down Expand Up @@ -432,7 +432,6 @@ export class TakeoverView implements Component, Focusable {
};
},
close: () => this.close(done),
send: (text) => view.requestSend(id, text),
abort: () => view.requestAbort(id),
});
this.unsubscribe = view.subscribeTo(id, () => {
Expand Down
46 changes: 22 additions & 24 deletions extensions/subagents/takeover.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import assert from "node:assert/strict";
import test from "node:test";
import { stripVTControlCharacters } from "node:util";
import type {
KeybindingsManager,
Theme,
Expand Down Expand Up @@ -201,6 +200,28 @@ test("chrome rows stay width-bounded and takeover uses three rules", () => {
}
});

test("takeover is a read-only child page without a message editor", () => {
const running = snap("run");
let sends = 0;
const list: SubagentReadModel = {
...model([running]),
requestSend() {
sends += 1;
},
};
const view = new TakeoverView(tui(20), theme, keys, "run", list, () => {});
try {
assert.doesNotMatch(view.render(80).join("\n"), /\bsend\b/);
view.handleInput("1");
view.handleInput("2");
view.handleInput("3");
view.handleInput("\r");
assert.equal(sends, 0);
} finally {
view.dispose();
}
});

test("takeover scroll indicator lives in its rule without changing overlay height", () => {
const transcript = Array.from({ length: 40 }, (_, index) => ({
kind: "assistant" as const,
Expand Down Expand Up @@ -277,26 +298,3 @@ test("takeover pauses on an absolute reading anchor and resumes at the end", ()
view.dispose();
}
});

test("Home and End edit non-empty input instead of moving the transcript", () => {
const view = new TakeoverView(
tui(20),
theme,
keys,
"run",
model([snap("run")]),
() => {},
);
try {
view.handleInput("abc");
view.handleInput("\u001b[H");
view.handleInput("X");
assert.match(stripVTControlCharacters(view.render(80).join("\n")), /Xabc/);

view.handleInput("\u001b[F");
view.handleInput("Y");
assert.match(stripVTControlCharacters(view.render(80).join("\n")), /XabcY/);
} finally {
view.dispose();
}
});
Loading