Skip to content

Commit 359f143

Browse files
authored
feat(tui): pi-style dock, Shift-Tab effort cycle, MCP loading line, pi-tui v1.0.1 (#369)
## Requirement or Bug Give the TUI pi's fixed-bottom dock, make Shift-Tab cycle thinking effort, replace per-server MCP lines with one loading line, and sync pi-tui to upstream v1.0.1. ## Bug Reproduction Steps N/A (feature PR). The Shift-Tab part fixes a mismatch: the welcome tip said "shift+tab cycles thinking effort", but Shift-Tab toggled plan mode. ## Root Cause N/A for the dock, effort cycle, and pi-tui sync. For the MCP line: each server that connected printed its own permanent `MCP server "x" connected · N tools` line. While fixing that, the startup status snapshot could also overwrite newer live `mcp.status` events, which could leave a stale "loading" state on screen. Live events now win over the snapshot. ## Code Changes Seven commits, each one slice: 1. **Fullscreen by default** (`tui/config.ts`, `tui-mode-selector.ts`): `DEFAULT_TUI_CONFIG.tuiMode` is `fullscreen`, and the "(experimental)" label is gone. On exit, fullscreen still replays the transcript to the main screen. 2. **Shift-Tab cycles effort** (`editor-keyboard.ts`, `pythinker-tui.ts`, `commands/config.ts`): Shift-Tab steps through the model's effort segments (without `off`) and wraps. Boolean-thinking models toggle on/off. It uses `performModelSwitch` with a new `quiet` option, so a key press adds no transcript line. The pick lasts for this session only (`/effort` still saves it). While a reply streams, the key does nothing. When no prompt highlight is active, the prompt frame uses the theme's `effort*` color; plan, bash, and slash highlights still take precedence. The tip, help panel, and docs are updated. 3. **Plain rules** (`custom-editor.ts`): the prompt is drawn as two `─` rules with no sides. When a `/btw` panel is attached above (`connectedAbove`), the old box is kept, so the panel border still closes. 4. **Spinner in the top rule** (`activity-pane.ts`, `activity-spinner.ts`, `custom-editor.ts`): while the agent works, the editor's top rule becomes `── ⠋ Working · tip ───`. The spinner keeps its lifecycle; only where it draws changes, so the dock height does not change. 5. **MCP loading line** (`session-event-handler.ts`, new `mcp-loading-line.ts`): one `● Loading MCP: a, b` line with the shared blinking `STATUS_BULLET`. It lists only the servers still pending and is removed when none are left. Failed and needs-OAuth servers still print their own line, which stays. 6. **pi-tui upstream v1.0.1** (`packages/pi-tui`): a three-way merge from the old sync point `53816d7` (v0.85.1) to `a7229ddc`. Fork changes are kept. Upstream's private `asciiVisibleWidth` is renamed `asciiTabVisibleWidth`, because the fork already exports a function with that name. Upstream's new files received only `!`/bracket fixes for our stricter tsconfig. The darwin `.node` prebuilds were rebuilt from the reviewed `darwin-platform.m`. Their exports and linked libraries match upstream's v1.0.1 binaries. `UPSTREAM.md` records the new sync point. No new dependencies. 7. Changesets. ## Behavior Changes and Affected Users | Behavior | Before | After | Who relies on the old behavior | Escape hatch | |---|---|---|---|---| | Default TUI mode | `regular` (native scrollback) | `fullscreen` (fixed bottom dock, alt screen) | New installs and users with no `tui.toml`. Existing `tui.toml` files already contain `tui_mode = "regular"` and are not affected. | `tui_mode = "regular"`, `/settings` → TUI mode | | Shift-Tab | toggles plan mode | cycles thinking effort | TUI users who toggle plan mode with the key | `/plan` | | Shift-Tab while streaming | toggled plan mode | does nothing | same as above | `/plan` | | Prompt frame color | plan/bash/slash highlight, otherwise neutral | when not highlighted, the effort color | none | none (visual only) | | Prompt editor frame | rounded box | two plain rules (box kept while `/btw` is attached) | none | `tui_mode = "regular"` keeps the regular layout, but the frame is plain in both modes | | Working spinner | own row above the prompt | inside the prompt's top rule | none | none | | MCP startup output | one permanent line per connected server | one temporary blinking line; failed and needs-auth lines are unchanged | users who read tool counts from the chat | the welcome card still shows `MCP N connected`; `/mcp` lists servers | | Status snapshot vs live MCP events | snapshot could overwrite newer events | live events win | none | none | | pi-tui internals | v0.85.1 | v1.0.1 (WezTerm image scroll, Kitty non-PNG convert, ANSI order fix) | none | none | Affected modules: `apps/pythinker-code/src/tui/**`, `packages/pi-tui`, docs (`configuration/config-files.md`, `guides/interaction.md`, `reference/keyboard.md`). Print mode, web, desktop, ACP, and SDK are not touched. Tests: TUI config tests (default and pinned `regular`), the editor-keyboard Shift-Tab cycle, editor side-border and plain-rule tests, activity-pane rule placement, MCP loading-line appear/shrink/clear, and the pi-tui `node --test` suite (1101 pass). Checked manually with tmux at 150x45 and 80x24, in fullscreen and regular mode. Shift-Tab changed `max → low → high` on DeepSeek V4.1 Flash. A slow stdio MCP fixture showed the blinking line shrink and then clear. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [ ] I have linked a related issue (external PRs: issue must have a maintainer's `/approve`). - [x] I have added tests that prove my feature works. - [x] The behavior-change table above is complete, and every removed behavior or flipped default is named in the changeset and either has an escape hatch or was explicitly approved by a maintainer in this PR. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * The TUI now defaults to fullscreen, with the prompt fixed at the bottom and in-app scrolling. Choose Regular mode to keep chat in terminal scrollback; saved mode settings are preserved. * Shift-Tab cycles through the current model’s thinking efforts. Use `/plan` to toggle Plan mode. * Connecting MCP servers appear together in a temporary loading line; failures and servers needing authentication remain individually visible. * Added support for clipboard file paths, improved image display in supported terminals, and more responsive wheel scrolling. * **Improvements** * Autocomplete better handles paths, punctuation, and CJK text. LaTeX rendering supports more script and cases layouts. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 6305545 commit 359f143

76 files changed

Lines changed: 3380 additions & 758 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/periwinkle-primary.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
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.

‎.changeset/pi-style-dock.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
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).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
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.

‎.changeset/shift-tab-effort.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
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.

‎apps/pythinker-code/src/tui/commands/config.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,11 +401,31 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
401401
);
402402
}
403403

404+
/**
405+
* Shift+Tab: step to the next thinking effort of the current model and wrap.
406+
* `off` is skipped when the model declares concrete efforts. The switch is
407+
* session-only and quiet, so repeated presses do not fill the transcript.
408+
*/
409+
export async function cycleThinkingEffort(host: SlashCommandHost): Promise<void> {
410+
const alias = host.state.appState.model;
411+
const model = host.state.appState.availableModels[alias];
412+
if (model === undefined) return;
413+
if (host.state.appState.streamingPhase !== 'idle') return;
414+
const segments = segmentsFor(effectiveModelForHost(host, model));
415+
const hasEfforts = segments.some((effort) => effort !== 'off' && effort !== 'on');
416+
const cycle = hasEfforts ? segments.filter((effort) => effort !== 'off') : segments;
417+
if (cycle.length < 2) return;
418+
const index = cycle.indexOf(host.state.appState.thinkingEffort);
419+
const next = cycle[(index + 1) % cycle.length]!;
420+
await performModelSwitch(host, alias, next, false, true);
421+
}
422+
404423
async function performModelSwitch(
405424
host: SlashCommandHost,
406425
alias: string,
407426
effort: ThinkingEffort,
408427
persist: boolean,
428+
quiet = false,
409429
): Promise<void> {
410430
let session = host.session;
411431
if (session === undefined) {
@@ -488,6 +508,7 @@ async function performModelSwitch(
488508
}
489509
}
490510

511+
if (quiet) return;
491512
let status: string;
492513
if (effectiveModelChanged) {
493514
status = persist

‎apps/pythinker-code/src/tui/components/chrome/activity-spinner.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ export class ActivitySpinner extends Text {
9999
return this.inlineText;
100100
}
101101

102+
renderLine(): string {
103+
return this.displayText;
104+
}
105+
102106
private updateDisplay(): void {
103107
if (this.useVerbLabels) this.label = formatThinkingSpinnerLabel();
104108
const frame = this.frames[this.currentFrame]!;

‎apps/pythinker-code/src/tui/components/dialogs/help-panel.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export interface HelpPanelCommand {
3131

3232
/** Static list — keep in sync with the global editor bindings. */
3333
export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [
34-
{ keys: 'Shift-Tab', description: 'Toggle plan mode' },
34+
{ keys: 'Shift-Tab', description: 'Cycle thinking effort' },
3535
{ keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' },
3636
{ keys: 'Ctrl-O', description: 'Toggle tool output / compaction summary expansion' },
3737
{ keys: 'Ctrl-T', description: 'Expand / collapse the todo list (when truncated)' },

‎apps/pythinker-code/src/tui/components/dialogs/tui-mode-selector.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@ import type { TuiMode } from '../../config';
22
import { ChoicePickerComponent, type ChoiceOption } from './choice-picker';
33

44
const TUI_MODE_OPTIONS: readonly ChoiceOption[] = [
5+
{
6+
value: 'fullscreen',
7+
label: 'Fullscreen',
8+
description: 'Prompt fixed at the bottom; in-app scrolling, selection, and transcript search.',
9+
},
510
{
611
value: 'regular',
712
label: 'Regular',
813
description: 'Render into the terminal\'s native scrollback.',
914
},
10-
{
11-
value: 'fullscreen',
12-
label: 'Fullscreen (experimental)',
13-
description: 'Alternate screen with in-app scrolling, selection, and transcript search.',
14-
},
1515
];
1616

1717
export interface TuiModeSelectorOptions {

‎apps/pythinker-code/src/tui/components/editor/custom-editor.ts‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import {
88
matchesKey,
99
Key,
1010
SelectList,
11+
truncateToWidth,
1112
visibleWidth,
1213
type SelectItem,
1314
type TUI,
1415
} from '@pymodel/pi-tui';
1516

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

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

855+
function ruleWithStatus(
856+
status: ActivitySpinner,
857+
width: number,
858+
paint: (s: string) => string,
859+
): string {
860+
status.setAvailableWidth(Math.max(1, width - 4));
861+
const text = truncateToWidth(status.renderLine(), Math.max(1, width - 4), '');
862+
return paint('── ') + text + ' ' + paint('─'.repeat(Math.max(0, width - 4 - visibleWidth(text))));
863+
}
864+
845865
/**
846866
* Post-process pi-tui's editor output to draw a full box around it.
847867
*
@@ -854,22 +874,29 @@ export function injectPromptSymbol(
854874
* only if they're literal spaces — that protects the cursor-overflow
855875
* case where the rightmost column is an SGR-tagged inverse cursor.
856876
*
877+
* With `options.plain`, rule rows keep plain `─` ends and content rows get
878+
* no side bars: the prompt reads as two horizontal rules, like pi.
879+
*
857880
* When `options.label` is set, it is overlaid on the left of the top border
858881
* (e.g. the `! shell mode` badge), replacing the leading dashes. It is only
859882
* applied to a plain dash run, never to a `↑/↓ N more` scroll indicator.
860883
*/
861884
export function wrapWithSideBorders(
862885
lines: string[],
863886
paint: (s: string) => string,
864-
options: { readonly connectedAbove?: boolean; readonly label?: string } = {},
887+
options: {
888+
readonly connectedAbove?: boolean;
889+
readonly label?: string;
890+
readonly plain?: boolean;
891+
} = {},
865892
): string[] {
866893
let seenTop = false;
867894
return lines.map((line) => {
868895
const plain = stripSgr(line);
869896
if (plain.length > 0 && plain[0] === '─') {
870897
const isTop = !seenTop;
871-
const leftCorner = seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭';
872-
const rightCorner = seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮';
898+
const leftCorner = options.plain === true ? '─' : seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭';
899+
const rightCorner = options.plain === true ? '─' : seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮';
873900
seenTop = true;
874901
if (plain.length === 1) return paint(leftCorner);
875902
const middle = plain.slice(1, -1);
@@ -886,7 +913,7 @@ export function wrapWithSideBorders(
886913
}
887914
return paint(leftCorner + middle + rightCorner);
888915
}
889-
if (line.length === 0) return line;
916+
if (line.length === 0 || options.plain === true) return line;
890917
const firstCh = line[0];
891918
const lastCh = line.at(-1);
892919
const head = firstCh === ' ' ? paint('│') : (firstCh ?? '');

0 commit comments

Comments
 (0)