mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): add ui mode setting
This commit is contained in:
parent
696a828a4d
commit
f074efd921
14 changed files with 121 additions and 30 deletions
|
|
@ -5,9 +5,9 @@
|
|||
### Added
|
||||
|
||||
- Added chainable `pi.registerMarkdownTransformer()` hooks for display-only transformation of user and assistant Markdown.
|
||||
- Added `--alt` to run interactive mode with the alternate-screen TUI ([#7304](https://github.com/earendil-works/pi/issues/7304)).
|
||||
- Added a sticky editor, status, widget, and footer dock to `--alt` mode while keeping the transcript independently scrollable.
|
||||
- Added a transient draggable transcript scrollbar to `--alt` mode.
|
||||
- Added an experimental fullscreen UI mode, selectable through `--ui-mode fullscreen` or `/settings` ([#7304](https://github.com/earendil-works/pi/issues/7304)).
|
||||
- Added a sticky editor, status, widget, and footer dock to fullscreen mode while keeping the transcript independently scrollable.
|
||||
- Added a transient draggable transcript scrollbar to fullscreen mode.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -605,7 +605,7 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set
|
|||
|--------|-------------|
|
||||
| `--system-prompt <text>` | Replace default prompt (context files and skills still appended) |
|
||||
| `--append-system-prompt <text>` | Append to system prompt |
|
||||
| `--alt` | Use the alternate-screen TUI with application-owned scrolling in interactive mode |
|
||||
| `--ui-mode <mode>` | UI mode: `regular` (default) or experimental `fullscreen` |
|
||||
| `--verbose` | Force verbose startup |
|
||||
| `-a`, `--approve` | Trust project-local files for this run |
|
||||
| `-na`, `--no-approve` | Ignore project-local files for this run |
|
||||
|
|
|
|||
|
|
@ -78,9 +78,9 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1
|
|||
| `tui.select.confirm` | `enter` | Confirm selection |
|
||||
| `tui.select.cancel` | `escape`, `ctrl+c` | Cancel selection |
|
||||
|
||||
### TUI Alternate-Screen Viewport
|
||||
### TUI Fullscreen Viewport
|
||||
|
||||
These actions apply when interactive mode is started with `--alt` and target the primary transcript scroll region. Two-finger trackpad and mouse-wheel input scroll the region under the pointer, falling back to the transcript over the fixed editor/status/footer dock. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects text and copies it to the clipboard; holding at the transcript's top or bottom edge auto-scrolls into off-screen content.
|
||||
These actions apply when interactive mode uses `--ui-mode fullscreen` and target the primary transcript scroll region. Two-finger trackpad and mouse-wheel input scroll the region under the pointer, falling back to the transcript over the fixed editor/status/footer dock. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects text and copies it to the clipboard; holding at the transcript's top or bottom edge auto-scrolls into off-screen content.
|
||||
|
||||
| Keybinding id | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
|
|||
| `outputPad` | number | `1` | Horizontal padding for user messages, assistant messages, and thinking (0 or 1) |
|
||||
| `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) |
|
||||
| `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support |
|
||||
| `uiMode` | string | `"regular"` | Interactive UI mode: `"regular"` or experimental `"fullscreen"`. Changes from `/settings` apply after restart; `--ui-mode` overrides this setting for one run |
|
||||
|
||||
For VS Code, include `--wait` so pi resumes after the editor exits:
|
||||
|
||||
|
|
|
|||
|
|
@ -239,14 +239,16 @@ pi --no-extensions -e ./my-extension.ts
|
|||
|--------|-------------|
|
||||
| `--system-prompt <text>` | Replace default prompt; context files and skills are still appended |
|
||||
| `--append-system-prompt <text>` | Append to system prompt |
|
||||
| `--alt` | Use the alternate-screen TUI with a scrollable transcript and fixed editor/status/footer dock |
|
||||
| `--ui-mode <mode>` | UI mode: `regular` (default) or experimental `fullscreen` |
|
||||
| `--verbose` | Force verbose startup |
|
||||
| `-a`, `--approve` | Trust project-local files for this run |
|
||||
| `-na`, `--no-approve` | Ignore project-local files for this run |
|
||||
| `-h`, `--help` | Show help |
|
||||
| `-v`, `--version` | Show version |
|
||||
|
||||
When `--alt` is active, the transcript scrolls inside the terminal viewport while queued messages, working status, extension widgets, editor, and footer remain fixed at the bottom. Mouse/trackpad input scrolls the region under the pointer; keyboard viewport actions always remain available. Inline images work in terminals that support the Kitty graphics protocol, including Kitty and Ghostty. In iTerm2 they render as text placeholders because its inline-image protocol cannot delete or crop placements during application-owned scrolling. Without `--alt`, pi uses the main screen and terminal-owned scrollback, and iTerm2 inline images continue to render normally.
|
||||
In `fullscreen` mode, the transcript scrolls inside the terminal viewport while queued messages, working status, extension widgets, editor, and footer remain fixed at the bottom. Mouse/trackpad input scrolls the region under the pointer; keyboard viewport actions always remain available. Inline images work in terminals that support the Kitty graphics protocol, including Kitty and Ghostty. In iTerm2 they render as text placeholders because its inline-image protocol cannot delete or crop placements during application-owned scrolling. In `regular` mode, pi uses the main screen and terminal-owned scrollback, and iTerm2 inline images continue to render normally.
|
||||
|
||||
To use fullscreen mode by default, set **UI mode** to `fullscreen` in `/settings`. The change applies after restarting pi.
|
||||
|
||||
### File Arguments
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|||
import chalk from "chalk";
|
||||
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR } from "../config.ts";
|
||||
import type { ExtensionFlag } from "../core/extensions/types.ts";
|
||||
import type { UiMode } from "../core/settings-manager.ts";
|
||||
|
||||
export type Mode = "text" | "json" | "rpc";
|
||||
|
||||
|
|
@ -45,7 +46,7 @@ export interface Args {
|
|||
noContextFiles?: boolean;
|
||||
listModels?: string | true;
|
||||
offline?: boolean;
|
||||
alt?: boolean;
|
||||
uiMode?: UiMode;
|
||||
verbose?: boolean;
|
||||
projectTrustOverride?: boolean;
|
||||
messages: string[];
|
||||
|
|
@ -176,8 +177,22 @@ export function parseArgs(args: string[]): Args {
|
|||
} else {
|
||||
result.listModels = true;
|
||||
}
|
||||
} else if (arg === "--ui-mode") {
|
||||
const mode = args[i + 1];
|
||||
if (mode === "regular" || mode === "fullscreen") {
|
||||
result.uiMode = mode;
|
||||
i++;
|
||||
} else if (mode === undefined || mode.startsWith("-")) {
|
||||
result.diagnostics.push({ type: "error", message: "--ui-mode requires regular or fullscreen" });
|
||||
} else {
|
||||
i++;
|
||||
result.diagnostics.push({
|
||||
type: "error",
|
||||
message: `Invalid UI mode "${mode}". Valid values: regular, fullscreen`,
|
||||
});
|
||||
}
|
||||
} else if (arg === "--alt") {
|
||||
result.alt = true;
|
||||
result.uiMode = "fullscreen";
|
||||
} else if (arg === "--verbose") {
|
||||
result.verbose = true;
|
||||
} else if (arg === "--approve" || arg === "-a") {
|
||||
|
|
@ -275,7 +290,7 @@ ${chalk.bold("Options:")}
|
|||
--export <file> Export session file to HTML and exit
|
||||
--list-models [search] List available models (with optional fuzzy search)
|
||||
--verbose Force verbose startup (overrides quietStartup setting)
|
||||
--alt Use the alternate-screen TUI in interactive mode
|
||||
--ui-mode <mode> UI mode: regular (default) or fullscreen
|
||||
--approve, -a Trust project-local files for this run
|
||||
--no-approve, -na Ignore project-local files for this run
|
||||
--offline Disable startup network operations (same as PI_OFFLINE=1)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export interface RetrySettings {
|
|||
provider?: ProviderRetrySettings;
|
||||
}
|
||||
|
||||
export type UiMode = "regular" | "fullscreen";
|
||||
|
||||
export interface TerminalSettings {
|
||||
showImages?: boolean; // default: true (only relevant if terminal supports images)
|
||||
imageWidthCells?: number; // default: 60 (preferred inline image width in terminal cells)
|
||||
|
|
@ -126,6 +128,7 @@ export interface Settings {
|
|||
httpProxy?: string; // Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients
|
||||
httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it
|
||||
websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it
|
||||
uiMode?: UiMode; // default: "regular"
|
||||
}
|
||||
|
||||
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
|
||||
|
|
@ -1120,6 +1123,16 @@ export class SettingsManager {
|
|||
this.save();
|
||||
}
|
||||
|
||||
getUiMode(): UiMode {
|
||||
return this.settings.uiMode === "fullscreen" ? "fullscreen" : "regular";
|
||||
}
|
||||
|
||||
setUiMode(mode: UiMode): void {
|
||||
this.globalSettings.uiMode = mode;
|
||||
this.markModified("uiMode");
|
||||
this.save();
|
||||
}
|
||||
|
||||
getImageAutoResize(): boolean {
|
||||
return this.settings.images?.autoResize ?? true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ export {
|
|||
type RetrySettings,
|
||||
SettingsManager,
|
||||
type SettingsManagerCreateOptions,
|
||||
type UiMode,
|
||||
} from "./core/settings-manager.ts";
|
||||
// Skills
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -877,7 +877,7 @@ export async function main(args: string[], options?: MainOptions) {
|
|||
initialImages,
|
||||
initialMessages: parsed.messages,
|
||||
verbose: parsed.verbose,
|
||||
alt: parsed.alt,
|
||||
uiMode: parsed.uiMode,
|
||||
});
|
||||
if (startupBenchmark) {
|
||||
await interactiveMode.init();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
Text,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
|
||||
import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts";
|
||||
import type { DefaultProjectTrust, UiMode, WarningSettings } from "../../../core/settings-manager.ts";
|
||||
import {
|
||||
getSelectListTheme,
|
||||
getSettingsListTheme,
|
||||
|
|
@ -79,6 +79,7 @@ export interface SettingsConfig {
|
|||
defaultProjectTrust: DefaultProjectTrust;
|
||||
clearOnShrink: boolean;
|
||||
showTerminalProgress: boolean;
|
||||
uiMode: UiMode;
|
||||
warnings: WarningSettings;
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +111,7 @@ export interface SettingsCallbacks {
|
|||
onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void;
|
||||
onClearOnShrinkChange: (enabled: boolean) => void;
|
||||
onShowTerminalProgressChange: (enabled: boolean) => void;
|
||||
onUiModeChange: (mode: UiMode) => void;
|
||||
onWarningsChange: (warnings: WarningSettings) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
|
@ -610,6 +612,13 @@ export class SettingsSelectorComponent extends Container {
|
|||
() => done(),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ui-mode",
|
||||
label: "UI mode",
|
||||
description: "Interface layout used after restart; fullscreen mode is experimental",
|
||||
currentValue: config.uiMode,
|
||||
values: ["regular", "fullscreen"],
|
||||
},
|
||||
{
|
||||
id: "theme",
|
||||
label: "Theme",
|
||||
|
|
@ -819,6 +828,9 @@ export class SettingsSelectorComponent extends Container {
|
|||
case "terminal-progress":
|
||||
callbacks.onShowTerminalProgressChange(newValue === "true");
|
||||
break;
|
||||
case "ui-mode":
|
||||
callbacks.onUiModeChange(newValue as UiMode);
|
||||
break;
|
||||
case "theme":
|
||||
callbacks.onThemeChange(newValue);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ import { DefaultPackageManager } from "../../core/package-manager.ts";
|
|||
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
|
||||
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
|
||||
import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.ts";
|
||||
import type { UiMode } from "../../core/settings-manager.ts";
|
||||
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
|
||||
import type { SourceInfo } from "../../core/source-info.ts";
|
||||
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
|
||||
|
|
@ -324,12 +325,12 @@ export interface InteractiveModeOptions {
|
|||
initialMessages?: string[];
|
||||
/** Force verbose startup (overrides quietStartup setting) */
|
||||
verbose?: boolean;
|
||||
/** Use the alternate-screen TUI renderer. */
|
||||
alt?: boolean;
|
||||
/** UI layout mode. */
|
||||
uiMode?: UiMode;
|
||||
}
|
||||
|
||||
interface InteractiveTuiOptions {
|
||||
alt: boolean;
|
||||
uiMode: UiMode;
|
||||
showHardwareCursor: boolean;
|
||||
logDirectory: string;
|
||||
terminal?: Terminal;
|
||||
|
|
@ -338,7 +339,7 @@ interface InteractiveTuiOptions {
|
|||
/** Composition root for selecting the interactive terminal renderer. */
|
||||
export function createInteractiveTui(options: InteractiveTuiOptions): TUI {
|
||||
const terminal = options.terminal ?? new ProcessTerminal();
|
||||
if (options.alt) {
|
||||
if (options.uiMode === "fullscreen") {
|
||||
return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { openUrl: openBrowser });
|
||||
}
|
||||
return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);
|
||||
|
|
@ -473,7 +474,8 @@ export class InteractiveMode {
|
|||
|
||||
constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) {
|
||||
this.runtimeHost = runtimeHost;
|
||||
this.options = options;
|
||||
const uiMode = options.uiMode ?? this.settingsManager.getUiMode();
|
||||
this.options = { ...options, uiMode };
|
||||
this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd;
|
||||
this.runtimeHost.setBeforeSessionInvalidate(() => {
|
||||
this.resetExtensionUI();
|
||||
|
|
@ -483,7 +485,7 @@ export class InteractiveMode {
|
|||
});
|
||||
this.version = VERSION;
|
||||
this.ui = createInteractiveTui({
|
||||
alt: options.alt ?? false,
|
||||
uiMode,
|
||||
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
|
||||
logDirectory: getAgentDir(),
|
||||
});
|
||||
|
|
@ -1929,7 +1931,7 @@ export class InteractiveMode {
|
|||
this.activeStatusIndicator?.dispose();
|
||||
this.activeStatusIndicator = undefined;
|
||||
this.statusContainer.clear();
|
||||
if (hadActiveStatusIndicator && !this.options.alt && this.ui.getClearOnShrink()) {
|
||||
if (hadActiveStatusIndicator && this.options.uiMode === "regular" && this.ui.getClearOnShrink()) {
|
||||
this.statusContainer.addChild(this.idleStatus);
|
||||
}
|
||||
}
|
||||
|
|
@ -4234,6 +4236,7 @@ export class InteractiveMode {
|
|||
quietStartup: this.settingsManager.getQuietStartup(),
|
||||
clearOnShrink: this.settingsManager.getClearOnShrink(),
|
||||
showTerminalProgress: this.settingsManager.getShowTerminalProgress(),
|
||||
uiMode: this.settingsManager.getUiMode(),
|
||||
warnings: this.settingsManager.getWarnings(),
|
||||
},
|
||||
{
|
||||
|
|
@ -4374,6 +4377,10 @@ export class InteractiveMode {
|
|||
onShowTerminalProgressChange: (enabled) => {
|
||||
this.settingsManager.setShowTerminalProgress(enabled);
|
||||
},
|
||||
onUiModeChange: (mode) => {
|
||||
this.settingsManager.setUiMode(mode);
|
||||
this.showStatus(`UI mode: ${mode} (restart required)`);
|
||||
},
|
||||
onWarningsChange: (warnings) => {
|
||||
this.settingsManager.setWarnings(warnings);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -329,10 +329,27 @@ describe("parseArgs", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("--alt flag", () => {
|
||||
test("parses --alt flag", () => {
|
||||
describe("--ui-mode flag", () => {
|
||||
test.each(["regular", "fullscreen"] as const)("parses %s mode", (mode) => {
|
||||
const result = parseArgs(["--ui-mode", mode]);
|
||||
expect(result.uiMode).toBe(mode);
|
||||
});
|
||||
|
||||
test("rejects invalid modes", () => {
|
||||
const result = parseArgs(["--ui-mode", "other"]);
|
||||
expect(result.diagnostics).toEqual([
|
||||
{ type: "error", message: 'Invalid UI mode "other". Valid values: regular, fullscreen' },
|
||||
]);
|
||||
});
|
||||
|
||||
test("requires a mode", () => {
|
||||
const result = parseArgs(["--ui-mode"]);
|
||||
expect(result.diagnostics).toEqual([{ type: "error", message: "--ui-mode requires regular or fullscreen" }]);
|
||||
});
|
||||
|
||||
test("accepts --alt as a hidden fullscreen shortcut", () => {
|
||||
const result = parseArgs(["--alt"]);
|
||||
expect(result.alt).toBe(true);
|
||||
expect(result.uiMode).toBe("fullscreen");
|
||||
expect(result.unknownFlags.has("alt")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ describe("createInteractiveTui", () => {
|
|||
it("selects the alternate-screen renderer only when requested", async () => {
|
||||
const mainTerminal = new RecordingTerminal();
|
||||
const mainTui = createInteractiveTui({
|
||||
alt: false,
|
||||
uiMode: "regular",
|
||||
showHardwareCursor: false,
|
||||
logDirectory: "/tmp",
|
||||
terminal: mainTerminal,
|
||||
|
|
@ -30,7 +30,7 @@ describe("createInteractiveTui", () => {
|
|||
|
||||
const altTerminal = new RecordingTerminal();
|
||||
const altTui = createInteractiveTui({
|
||||
alt: true,
|
||||
uiMode: "fullscreen",
|
||||
showHardwareCursor: false,
|
||||
logDirectory: "/tmp",
|
||||
terminal: altTerminal,
|
||||
|
|
@ -46,7 +46,7 @@ describe("createInteractiveTui", () => {
|
|||
type ClearStatusContext = {
|
||||
activeStatusIndicator: { kind: "working"; dispose: () => void } | undefined;
|
||||
statusContainer: Container;
|
||||
options: { alt?: boolean };
|
||||
options: { uiMode?: "regular" | "fullscreen" };
|
||||
ui: { getClearOnShrink: () => boolean };
|
||||
idleStatus: Component;
|
||||
};
|
||||
|
|
@ -59,15 +59,15 @@ const interactiveModePrototype = InteractiveMode.prototype as unknown as Interac
|
|||
|
||||
describe("clear-on-shrink status spacing", () => {
|
||||
it("reserves status height only on the main-screen renderer", () => {
|
||||
for (const [alt, expectedChildren] of [
|
||||
[false, 1],
|
||||
[true, 0],
|
||||
for (const [uiMode, expectedChildren] of [
|
||||
["regular", 1],
|
||||
["fullscreen", 0],
|
||||
] as const) {
|
||||
const dispose = vi.fn();
|
||||
const context: ClearStatusContext = {
|
||||
activeStatusIndicator: { kind: "working", dispose },
|
||||
statusContainer: new Container(),
|
||||
options: { alt },
|
||||
options: { uiMode },
|
||||
ui: { getClearOnShrink: () => true },
|
||||
idleStatus: new Text("", 0, 0),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -397,6 +397,29 @@ describe("SettingsManager", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("UI mode", () => {
|
||||
it("defaults to regular and persists fullscreen mode", async () => {
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
expect(manager.getUiMode()).toBe("regular");
|
||||
|
||||
manager.setUiMode("fullscreen");
|
||||
await manager.flush();
|
||||
|
||||
expect(manager.getUiMode()).toBe("fullscreen");
|
||||
const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8"));
|
||||
expect(savedSettings.uiMode).toBe("fullscreen");
|
||||
});
|
||||
|
||||
it("falls back to regular for unsupported values", () => {
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ uiMode: "other" }));
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
expect(manager.getUiMode()).toBe("regular");
|
||||
});
|
||||
});
|
||||
|
||||
describe("outputPad", () => {
|
||||
it("should default to 1 and persist binary values", async () => {
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue