Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/periwinkle-primary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

The TUI's main color (links, inline code, selections, focus, plan and shell mode) is now periwinkle instead of cyan in both the dark and light themes.
5 changes: 5 additions & 0 deletions .changeset/pi-style-dock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

The TUI now opens in fullscreen by default with the prompt fixed at the bottom, drawn as two plain rules with the working spinner inside the top rule; set `tui_mode = "regular"` in `tui.toml` to keep the chat in native terminal scrollback (existing `tui.toml` files keep their saved mode).
5 changes: 5 additions & 0 deletions .changeset/pi-tui-upstream-v1-0-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Update the terminal UI library to upstream pi v1.0.1, which keeps WezTerm images while scrolling, converts non-PNG images for Kitty, and fixes ANSI color order when a styled line is cut.
5 changes: 5 additions & 0 deletions .changeset/quiet-mcp-loading-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

The TUI now shows one "Loading MCP: …" line with a blinking dot and the names of the servers still connecting, and removes it once all have loaded, instead of one line per connected server; failures and servers that need OAuth still get their own line.
5 changes: 5 additions & 0 deletions .changeset/shift-tab-effort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

Shift-Tab now cycles the current model's thinking effort (for example low, medium, high, xhigh, max) and the prompt frame takes that effort's color; Shift-Tab no longer toggles plan mode, so use `/plan` instead.
21 changes: 21 additions & 0 deletions apps/pythinker-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,11 +401,31 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
);
}

/**
* Shift+Tab: step to the next thinking effort of the current model and wrap.
* `off` is skipped when the model declares concrete efforts. The switch is
* session-only and quiet, so repeated presses do not fill the transcript.
*/
export async function cycleThinkingEffort(host: SlashCommandHost): Promise<void> {
const alias = host.state.appState.model;
const model = host.state.appState.availableModels[alias];
if (model === undefined) return;
if (host.state.appState.streamingPhase !== 'idle') return;
const segments = segmentsFor(effectiveModelForHost(host, model));
const hasEfforts = segments.some((effort) => effort !== 'off' && effort !== 'on');
const cycle = hasEfforts ? segments.filter((effort) => effort !== 'off') : segments;
if (cycle.length < 2) return;
const index = cycle.indexOf(host.state.appState.thinkingEffort);
const next = cycle[(index + 1) % cycle.length]!;
await performModelSwitch(host, alias, next, false, true);
}

async function performModelSwitch(
host: SlashCommandHost,
alias: string,
effort: ThinkingEffort,
persist: boolean,
quiet = false,
): Promise<void> {
let session = host.session;
if (session === undefined) {
Expand Down Expand Up @@ -488,6 +508,7 @@ async function performModelSwitch(
}
}

if (quiet) return;
let status: string;
if (effectiveModelChanged) {
status = persist
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export class ActivitySpinner extends Text {
return this.inlineText;
}

renderLine(): string {
return this.displayText;
}

private updateDisplay(): void {
if (this.useVerbLabels) this.label = formatThinkingSpinnerLabel();
const frame = this.frames[this.currentFrame]!;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface HelpPanelCommand {

/** Static list — keep in sync with the global editor bindings. */
export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [
{ keys: 'Shift-Tab', description: 'Toggle plan mode' },
{ keys: 'Shift-Tab', description: 'Cycle thinking effort' },
{ keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' },
{ keys: 'Ctrl-O', description: 'Toggle tool output / compaction summary expansion' },
{ keys: 'Ctrl-T', description: 'Expand / collapse the todo list (when truncated)' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import type { TuiMode } from '../../config';
import { ChoicePickerComponent, type ChoiceOption } from './choice-picker';

const TUI_MODE_OPTIONS: readonly ChoiceOption[] = [
{
value: 'fullscreen',
label: 'Fullscreen',
description: 'Prompt fixed at the bottom; in-app scrolling, selection, and transcript search.',
},
{
value: 'regular',
label: 'Regular',
description: 'Render into the terminal\'s native scrollback.',
},
{
value: 'fullscreen',
label: 'Fullscreen (experimental)',
description: 'Alternate screen with in-app scrolling, selection, and transcript search.',
},
];

export interface TuiModeSelectorOptions {
Expand Down
37 changes: 32 additions & 5 deletions apps/pythinker-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import {
matchesKey,
Key,
SelectList,
truncateToWidth,
visibleWidth,
type SelectItem,
type TUI,
} from '@pymodel/pi-tui';

import type { ActivitySpinner } from '#/tui/components/chrome/activity-spinner';
import { currentTheme } from '#/tui/theme';
import { createEditorTheme } from '#/tui/theme/pi-tui-theme';
import { printableChar } from '#/tui/utils/printable-key';
Expand Down Expand Up @@ -154,6 +156,8 @@ export class CustomEditor extends Editor {
public inputMode: 'prompt' | 'bash' = 'prompt';
public onInputModeChange?: (mode: 'prompt' | 'bash') => void;
public connectedAbove = false;
/** Activity spinner drawn inside the top rule while the agent works (pi style). */
public ruleStatus: ActivitySpinner | undefined;
public borderHighlighted = false;
/**
* Called when the user triggers "paste image" (Ctrl-V on Unix,
Expand Down Expand Up @@ -341,10 +345,16 @@ export class CustomEditor extends Editor {
// overwrite it (e.g. plan-mode / slash-context highlight via
// `editor.borderColor = chalk.hex(primary)`), so we route corners and
// side bars through the same hook to stay in sync.
return wrapWithSideBorders(lines, (s) => this.borderColor(s), {
const wrapped = wrapWithSideBorders(lines, (s) => this.borderColor(s), {
connectedAbove: this.connectedAbove && !this.borderHighlighted,
label: isBash ? ` ${currentTheme.boldFg('shellMode', '! shell mode')} ` : undefined,
plain: !this.connectedAbove,
});
const top = wrapped[0];
if (this.ruleStatus !== undefined && !isBash && !this.connectedAbove && top !== undefined && /^─+$/.test(stripSgr(top))) {
wrapped[0] = ruleWithStatus(this.ruleStatus, width, (s) => this.borderColor(s));
}
return wrapped;
}

private computeArgumentHint(): string | undefined {
Expand Down Expand Up @@ -842,6 +852,16 @@ export function injectPromptSymbol(
return ' ' + rendered + ' ' + line.slice(4);
}

function ruleWithStatus(
status: ActivitySpinner,
width: number,
paint: (s: string) => string,
): string {
status.setAvailableWidth(Math.max(1, width - 4));
const text = truncateToWidth(status.renderLine(), Math.max(1, width - 4), '');
return paint('── ') + text + ' ' + paint('─'.repeat(Math.max(0, width - 4 - visibleWidth(text))));
}

/**
* Post-process pi-tui's editor output to draw a full box around it.
*
Expand All @@ -854,22 +874,29 @@ export function injectPromptSymbol(
* only if they're literal spaces — that protects the cursor-overflow
* case where the rightmost column is an SGR-tagged inverse cursor.
*
* With `options.plain`, rule rows keep plain `─` ends and content rows get
* no side bars: the prompt reads as two horizontal rules, like pi.
*
* When `options.label` is set, it is overlaid on the left of the top border
* (e.g. the `! shell mode` badge), replacing the leading dashes. It is only
* applied to a plain dash run, never to a `↑/↓ N more` scroll indicator.
*/
export function wrapWithSideBorders(
lines: string[],
paint: (s: string) => string,
options: { readonly connectedAbove?: boolean; readonly label?: string } = {},
options: {
readonly connectedAbove?: boolean;
readonly label?: string;
readonly plain?: boolean;
} = {},
): string[] {
let seenTop = false;
return lines.map((line) => {
const plain = stripSgr(line);
if (plain.length > 0 && plain[0] === '─') {
const isTop = !seenTop;
const leftCorner = seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭';
const rightCorner = seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮';
const leftCorner = options.plain === true ? '─' : seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭';
const rightCorner = options.plain === true ? '─' : seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮';
seenTop = true;
if (plain.length === 1) return paint(leftCorner);
const middle = plain.slice(1, -1);
Expand All @@ -886,7 +913,7 @@ export function wrapWithSideBorders(
}
return paint(leftCorner + middle + rightCorner);
}
if (line.length === 0) return line;
if (line.length === 0 || options.plain === true) return line;
const firstCh = line[0];
const lastCh = line.at(-1);
const head = firstCh === ' ' ? paint('│') : (firstCh ?? '');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Text } from '@pymodel/pi-tui';
import type { TUI } from '@pymodel/pi-tui';

import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';

const BLINK_INTERVAL_MS = 500;

/** Transient "Loading MCP: a, b" row with the same blinking bullet as running tools. */
export class McpLoadingLine extends Text {
private blinkOn = true;
private names: readonly string[];
private readonly timer: ReturnType<typeof setInterval>;

constructor(
private readonly ui: TUI,
names: readonly string[],
) {
super('', 1, 0);
this.names = names;
this.refresh();
this.timer = setInterval(() => {
this.blinkOn = !this.blinkOn;
this.refresh();
}, BLINK_INTERVAL_MS);
}

setNames(names: readonly string[]): void {
this.names = names;
this.refresh();
}

stop(): void {
clearInterval(this.timer);
}

private refresh(): void {
const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' ';
const label = currentTheme.fg('textMuted', `Loading MCP: ${this.names.join(', ')}`);
this.setText(`${bullet}${label}`);
this.ui.requestRender();
}
}
10 changes: 0 additions & 10 deletions apps/pythinker-code/src/tui/components/panes/activity-pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,8 @@ export function formatActivitySpinnerTip(tip: string | undefined): string {
}

export class ActivityPaneComponent extends Container {
private spinnerRef?: ActivitySpinner;

constructor(options: ActivityPaneOptions) {
super();
this.spinnerRef = options.spinner;

if (
(options.mode === 'waiting' ||
Expand All @@ -34,17 +31,10 @@ export class ActivityPaneComponent extends Container {
) {
this.addChild(new Spacer(1));
options.spinner.setTip(formatActivitySpinnerTip(options.tip));
this.addChild(options.spinner);
if (options.detail !== undefined && options.detail.length > 0) {
this.addChild(new Text(currentTheme.fg('textDim', options.detail), ACTIVITY_DETAIL_INDENT, 0));
}
}
}

override render(width: number): string[] {
if (this.spinnerRef && 'setAvailableWidth' in this.spinnerRef) {
this.spinnerRef.setAvailableWidth(width);
}
return super.render(width);
}
}
6 changes: 3 additions & 3 deletions apps/pythinker-code/src/tui/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = {

export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({
theme: 'auto',
tuiMode: 'regular',
tuiMode: 'fullscreen',
renderLatex: true,
disablePasteBurst: false,
cacheExpiryHint: true,
Expand Down Expand Up @@ -257,7 +257,7 @@ export function normalizeTuiConfig(
}
}
const tuiModeValue = config.tui_mode;
let tuiMode: TuiMode = 'regular';
let tuiMode: TuiMode = DEFAULT_TUI_CONFIG.tuiMode ?? 'fullscreen';
if (tuiModeValue !== undefined) {
if (tuiModeValue === 'regular' || tuiModeValue === 'fullscreen') {
tuiMode = tuiModeValue;
Expand Down Expand Up @@ -310,7 +310,7 @@ export function renderTuiConfig(config: TuiConfig): string {
if (statusCommand) {
statusLines.push(`command = "${escapeTomlBasicString(statusCommand)}"`);
}
const tuiModeLine = `tui_mode = "${config.tuiMode ?? 'regular'}" # "regular" | "fullscreen" ("fullscreen" is experimental)`;
const tuiModeLine = `tui_mode = "${config.tuiMode ?? 'fullscreen'}" # "fullscreen" | "regular" ("regular" keeps the chat in native terminal scrollback)`;
const markdownSection =
config.markdown?.mermaid === 'off'
? `[markdown]\nmermaid = "off" # "final" | "off"\n`
Expand Down
2 changes: 1 addition & 1 deletion apps/pythinker-code/src/tui/constant/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,6 @@ export const ALL_TIPS: readonly ToolbarTip[] = [
{ text: '/help: show commands' },
{ text: '/compact compresses context when it gets long', priority: 2 },
{ text: 'ctrl-o to hide or reveal tool output switching between a clean chat view and full execution details', priority: 2 },
{ text: 'shift-tab to Plan mode to review the approach before Pythinker edits files.', priority: 2 },
{ text: '/plan to review the approach before Pythinker edits files.', priority: 2 },
{ text: '/model: switch model', priority: 2 },
];
19 changes: 3 additions & 16 deletions apps/pythinker-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export interface EditorKeyboardHost {
openUndoSelector(): void;
stop(exitCode?: number): Promise<void>;
ensureSession(): Promise<Session | undefined>;
handlePlanToggle(next: boolean): void;
cycleThinkingEffort(): void;
handleInputModeChange(mode: 'prompt' | 'bash'): void;
clearQueuedMessages(): void;
setExternalEditorRunning(running: boolean): void;
Expand Down Expand Up @@ -264,21 +264,8 @@ export class EditorKeyboardController {
};

editor.onShiftTab = () => {
const togglePlan = (): void => {
const next = !host.state.appState.planMode;
host.track('shortcut_plan_toggle', { enabled: next });
host.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' });
host.handlePlanToggle(next);
};
if (host.session === undefined) {
// v2 session-less: lazy-create the session, then toggle — the same
// path /plan takes.
void host.ensureSession().then((session) => {
if (session !== undefined) togglePlan();
});
return;
}
togglePlan();
host.track('shortcut_effort_cycle');
host.cycleThinkingEffort();
};

editor.onInputModeChange = (mode) => {
Expand Down
Loading
Loading