Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.
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
5 changes: 5 additions & 0 deletions .changeset/pi-tui-084-fullscreen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"echadron": minor
---

Re-baseline the vendored pi-tui on 0.84.1 and add an experimental fullscreen mode. Pick it under `/settings` -> Display mode (or set `[tui] tui_mode = "fullscreen"`, or `ECHADRON_TUI_FULL_SCREEN=1` for a one-off run) to run on the alternate screen, where the transcript scrolls inside its own view and the activity, todo, queue, btw and editor chrome stays docked at the bottom instead of flowing away through the terminal's scrollback. The screen is chosen when the TUI starts, so switching asks you to restart. Inline mode is unchanged and remains the default. The upgrade also brings fullscreen transcript search, a large reduction in per-frame allocation on the alternate screen, SSH-aware escape timing, and Windows input fixes.
49 changes: 48 additions & 1 deletion apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import { PermissionSelectorComponent } from '../components/dialogs/permission-se
import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector';
import { ThemeSelectorComponent } from '../components/dialogs/theme-selector';
import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector';
import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config';
import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig, type TuiMode } from '../config';
import type { ThemeName } from '#/tui/theme';
import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme';
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
Expand Down Expand Up @@ -725,6 +726,51 @@ async function performSecondaryModelSwitch(
);
}

function showDisplayModePicker(host: SlashCommandHost): void {
const current = host.state.appState.tuiMode ?? 'inline';
host.mountEditorReplacement(
new ChoicePickerComponent({
title: ' Display mode',
options: [
{
value: 'inline',
label: 'Inline',
description: "Flow through the terminal's own scrollback (default).",
},
{
value: 'fullscreen',
label: 'Fullscreen',
description: 'Dock the chrome and scroll the transcript in its own view.',
},
],
currentValue: current,
onSelect: (value: string) => {
host.restoreEditor();
void applyDisplayMode(host, value === 'fullscreen' ? 'fullscreen' : 'inline');
},
onCancel: () => {
host.restoreEditor();
},
}),
);
}

async function applyDisplayMode(host: SlashCommandHost, tuiMode: TuiMode): Promise<void> {
if (tuiMode === (host.state.appState.tuiMode ?? 'inline')) {
host.showStatus(`Display mode unchanged: ${tuiMode}.`);
return;
}
try {
await saveTuiConfig({ ...currentTuiConfig(host), tuiMode });
} catch (error) {
host.showStatus(`Failed to save display mode: ${formatErrorMessage(error)}`, 'error');
return;
}
// The screen is chosen when the TUI is constructed, so this cannot swap live.
host.setAppState({ tuiMode });
host.showStatus(`Display mode set to ${tuiMode}. Restart Echadron to apply it.`);
}

function showThemePicker(host: SlashCommandHost): void {
host.mountEditorReplacement(
new ThemeSelectorComponent({
Expand Down Expand Up @@ -955,6 +1001,7 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio
case 'secondaryModel': void handleSecondaryModelCommand(host, ''); return;
case 'permission': showPermissionPicker(host); return;
case 'theme': showThemePicker(host); return;
case 'displayMode': showDisplayModePicker(host); return;
case 'editor': showEditorPicker(host); return;
case 'experiments': void showExperimentsPanel(host); return;
case 'upgrade': showUpdatePreferencePicker(host); return;
Expand Down
24 changes: 14 additions & 10 deletions apps/kimi-code/src/tui/components/dialogs/settings-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type SettingsSelection =
| 'model'
| 'secondaryModel'
| 'theme'
| 'displayMode'
| 'editor'
| 'permission'
| 'experiments'
Expand All @@ -17,13 +18,14 @@ export const SETTINGS_SELECTION_VALUES = [
'secondaryModel',
'permission',
'theme',
'displayMode',
'editor',
'experiments',
'upgrade',
'usage',
] as const satisfies readonly SettingsSelection[];

const SETTINGS_OPTIONS: readonly ChoiceOption[] = [
export const SETTINGS_OPTIONS: readonly ChoiceOption[] = [
{
value: 'model',
label: 'Model',
Expand All @@ -44,6 +46,11 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [
label: 'Theme',
description: 'Change the terminal UI theme.',
},
{
value: 'displayMode',
label: 'Display mode',
description: 'Flow through terminal scrollback, or dock the chrome fullscreen.',
},
{
value: 'editor',
label: 'Editor',
Expand All @@ -66,16 +73,13 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [
},
];

/**
* Derived from `SETTINGS_SELECTION_VALUES` rather than hand-listed. A
* hardcoded whitelist silently drops any entry added to the menu but missed
* here — the row renders and selecting it does nothing at all, with no error.
*/
function isSettingsSelection(value: string): value is SettingsSelection {
return (
value === 'model' ||
value === 'theme' ||
value === 'editor' ||
value === 'permission' ||
value === 'experiments' ||
value === 'upgrade' ||
value === 'usage'
);
return (SETTINGS_SELECTION_VALUES as readonly string[]).includes(value);
}

export interface SettingsSelectorOptions {
Expand Down
16 changes: 16 additions & 0 deletions apps/kimi-code/src/tui/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export const TuiThemeSchema = z.string();

export const NotificationConditionSchema = z.enum(['unfocused', 'always']);

/**
* `inline` flows the whole UI through the terminal's own scrollback.
* `fullscreen` mounts the alternate screen: the transcript scrolls in its own
* view and the chrome stays docked at the bottom. Chosen when the TUI is
* constructed, so a change needs a restart.
*/
export const TuiModeSchema = z.enum(['inline', 'fullscreen']);
export type TuiMode = z.infer<typeof TuiModeSchema>;

export const NotificationsConfigSchema = z.object({
enabled: z.boolean(),
condition: NotificationConditionSchema,
Expand All @@ -34,6 +43,7 @@ export const TuiConfigFileSchema = z.object({
theme: TuiThemeSchema.optional(),
disable_paste_burst: z.boolean().optional(),
cache_expiry_hint: z.boolean().optional(),
tui_mode: TuiModeSchema.optional(),
editor: z
.object({
command: z.string().optional(),
Expand All @@ -58,6 +68,9 @@ export const TuiConfigSchema = z.object({
/** Present in every normalized config; optional only so hand-built test
* fixtures from before this field existed still typecheck. */
cacheExpiryHint: z.boolean().optional(),
/** Present in every normalized config; optional only so hand-built test
* fixtures from before this field existed still typecheck. */
tuiMode: TuiModeSchema.optional(),
editorCommand: z.string().nullable(),
notifications: NotificationsConfigSchema,
upgrade: UpgradePreferencesSchema,
Expand All @@ -81,6 +94,7 @@ export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({
theme: 'auto',
disablePasteBurst: false,
cacheExpiryHint: true,
tuiMode: 'inline',
editorCommand: null,
notifications: DEFAULT_NOTIFICATIONS_CONFIG,
upgrade: DEFAULT_UPGRADE_PREFERENCES,
Expand Down Expand Up @@ -142,6 +156,7 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig {
theme: config.theme ?? DEFAULT_TUI_CONFIG.theme,
disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint,
tuiMode: config.tui_mode ?? DEFAULT_TUI_CONFIG.tuiMode,
editorCommand: command === undefined || command.length === 0 ? null : command,
notifications: {
enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled,
Expand All @@ -162,6 +177,7 @@ export function renderTuiConfig(config: TuiConfig): string {
theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name
disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback
cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit
tui_mode = "${config.tuiMode ?? 'inline'}" # "inline" flows through terminal scrollback | "fullscreen" docks the chrome on the alternate screen (restart to apply)

[editor]
command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
editorCommand: input.tuiConfig.editorCommand,
disablePasteBurst: input.tuiConfig.disablePasteBurst,
cacheExpiryHint: input.tuiConfig.cacheExpiryHint,
tuiMode: input.tuiConfig.tuiMode,
notifications: input.tuiConfig.notifications,
upgrade: input.tuiConfig.upgrade,
availableModels: {},
Expand Down
52 changes: 50 additions & 2 deletions apps/kimi-code/src/tui/tui-state.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import {
Container,
ProcessTerminal,
TUI,
ScrollView,
TuiAltScreen,
TuiMainScreen,
VStack,
type TUI,
} from '@moonshot-ai/pi-tui';

import { FooterComponent } from './components/chrome/footer';
Expand Down Expand Up @@ -60,14 +64,32 @@ export interface TUIState {
*/
queuedMessageDispatchPending: boolean;
swarmModeEntry: 'manual' | 'task' | undefined;
/**
* Fullscreen only: the bottom dock (activity / todo / queue / btw / editor)
* stacked under the transcript ScrollView. Undefined in regular mode, where
* every piece of chrome is a direct child of the root container.
*/
dockContainer: VStack | undefined;
}

export function createTUIState(options: KimiTUIOptions): TUIState {
const initialAppState = options.initialAppState;
const theme = currentTheme;

const terminal = new ProcessTerminal();
const ui = new TUI(terminal);
// Fullscreen is experimental and env-gated: ECHADRON_TUI_FULL_SCREEN=1.
// The alternate screen scrolls the transcript inside a primary ScrollView
// and docks the rest of the chrome at the bottom, instead of letting the
// whole UI flow through the terminal's own scrollback.
// `[tui] tui_mode` is the setting; the env override stays for one-off runs
// and for trying the mode without touching tui.toml.
const envOverride =
process.env['ECHADRON_TUI_FULL_SCREEN'] ?? process.env['KIMI_CODE_TUI_FULL_SCREEN'];
const fullscreen =
envOverride === undefined
? initialAppState.tuiMode === 'fullscreen'
: envOverride === '1';
const ui: TUI = fullscreen ? new TuiAltScreen(terminal) : new TuiMainScreen(terminal);

const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
Expand All @@ -83,8 +105,34 @@ export function createTUIState(options: KimiTUIOptions): TUIState {
ui.requestRender();
});

let dockContainer: VStack | undefined;
if (ui instanceof TuiAltScreen) {
// The transcript scrolls inside the primary ScrollView; everything else
// stays docked at the bottom. Sizing mirrors pi's interactive layout: the
// transcript starts from basis 0 and grows, while the dock keeps its
// intrinsic height and the editor is never squeezed below its three rows
// (top border / input / bottom border) or its outline gets clipped.
const scrollView = new ScrollView(transcriptContainer, {
follow: 'end',
primary: true,
overscroll: 'chain',
scrollbar: 'auto',
});
dockContainer = new VStack();
dockContainer.addChild(activityContainer, { shrink: 1, minSize: 0 });
dockContainer.addChild(todoPanelContainer, { shrink: 1, minSize: 0 });
dockContainer.addChild(queueContainer, { shrink: 1, minSize: 0 });
dockContainer.addChild(btwPanelContainer, { shrink: 1, minSize: 0 });
dockContainer.addChild(editorContainer, { shrink: 1, minSize: 3 });
const root = new VStack();
root.addChild(scrollView, { basis: 0, grow: 1, shrink: 1, minSize: 1 });
root.addChild(dockContainer, { basis: 'auto', grow: 0, shrink: 1, minSize: 1 });
ui.setLayoutRoot(root);
}

return {
ui,
dockContainer,
terminal,
transcriptContainer,
activityContainer,
Expand Down
3 changes: 3 additions & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { TuiMode } from '#/tui/config';
import type {
GoalChange,
GoalSnapshot,
Expand Down Expand Up @@ -57,6 +58,8 @@ export interface AppState {
disablePasteBurst?: boolean;
/** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */
cacheExpiryHint?: boolean;
/** `[tui] tui_mode`; read once when the TUI is constructed. */
tuiMode?: TuiMode;
notifications: NotificationsConfig;
upgrade: UpgradePreferences;
availableModels: Record<string, ModelAlias>;
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/test/tui/commands/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ describe('built-in slash command registry', () => {
const reachableFromSettings = new Set<string>(SETTINGS_SELECTION_VALUES);
const hiddenToSettings: Record<string, string> = {
theme: 'theme',
// display mode has no slash command; it is settings-only

editor: 'editor',
features: 'experiments',
secondary_model: 'secondaryModel',
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/test/tui/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ auto_install = false
theme: 'light',
disablePasteBurst: false,
cacheExpiryHint: true,
tuiMode: 'inline',
editorCommand: 'code --wait',
notifications: { enabled: false, condition: 'always' },
upgrade: { autoInstall: false },
Expand Down Expand Up @@ -96,6 +97,7 @@ command = " "
theme: 'auto',
disablePasteBurst: false,
cacheExpiryHint: true,
tuiMode: 'inline',
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },
Expand Down Expand Up @@ -129,6 +131,7 @@ command = " "
theme: 'light',
disablePasteBurst: false,
cacheExpiryHint: true,
tuiMode: 'inline',
editorCommand: 'vim',
notifications: { enabled: false, condition: 'always' },
upgrade: { autoInstall: false },
Expand All @@ -140,6 +143,7 @@ command = " "
theme: 'light',
disablePasteBurst: false,
cacheExpiryHint: true,
tuiMode: 'inline',
editorCommand: 'vim',
notifications: { enabled: false, condition: 'always' },
upgrade: { autoInstall: false },
Expand All @@ -153,6 +157,7 @@ command = " "
theme,
disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst,
cacheExpiryHint: DEFAULT_TUI_CONFIG.cacheExpiryHint,
tuiMode: 'inline',
editorCommand: null,
notifications: DEFAULT_TUI_CONFIG.notifications,
upgrade: DEFAULT_TUI_CONFIG.upgrade,
Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-code/test/tui/tui-frame.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/

import type { Component, Terminal } from '@moonshot-ai/pi-tui';
import { TUI } from '@moonshot-ai/pi-tui';
import { TuiMainScreen } from '@moonshot-ai/pi-tui';
import { bench, describe } from 'vitest';

const WIDTH = 120;
Expand Down Expand Up @@ -72,7 +72,7 @@ class SpinnerComponent implements Component {

describe('TUI steady-state frame', () => {
const terminal = new StubTerminal();
const tui = new TUI(terminal);
const tui = new TuiMainScreen(terminal);
const spinner = new SpinnerComponent();
tui.addChild(
new StaticTranscript(
Expand Down
28 changes: 28 additions & 0 deletions apps/kimi-code/test/tui/utils/activity-face.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,31 @@ describe('activity face', () => {
);
});
});

describe('display mode setting', () => {
it('every row in the settings menu is actually selectable', async () => {
// The regression this guards: SettingsSelectorComponent runs each choice
// through a type guard before calling onSelect. When that guard was a
// hand-written whitelist, a row added to the menu but missed in the guard
// rendered normally and did nothing at all when picked — no error, no
// dispatch. Compare the menu against the guard's source of truth.
const { SETTINGS_OPTIONS, SETTINGS_SELECTION_VALUES } = await import(
'#/tui/components/dialogs/settings-selector'
);
const selectable = new Set<string>(SETTINGS_SELECTION_VALUES);
for (const option of SETTINGS_OPTIONS) {
expect(selectable.has(option.value), `menu row "${option.value}" is not selectable`).toBe(
true,
);
}
// And nothing is claimed selectable that the menu never offers.
expect(SETTINGS_OPTIONS.length).toBe(SETTINGS_SELECTION_VALUES.length);
expect([...SETTINGS_SELECTION_VALUES]).toContain('displayMode');
expect([...SETTINGS_SELECTION_VALUES]).toContain('secondaryModel');
});

it('defaults to inline so the alternate screen is opt-in', async () => {
const { DEFAULT_TUI_CONFIG } = await import('#/tui/config');
expect(DEFAULT_TUI_CONFIG.tuiMode).toBe('inline');
});
});
Loading
Loading