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
4 changes: 4 additions & 0 deletions .changeset/tui-footer-decode-speed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
"@pymodel/pythinker-code": patch
---
Show the step decode speed next to the context readout in the terminal footer.
4 changes: 4 additions & 0 deletions .changeset/tui-hatch-easter-egg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
"@pymodel/pythinker-code": patch
---
Rename the hidden /dance easter egg to /hatch.
4 changes: 4 additions & 0 deletions .changeset/tui-shift-tab-thinking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
"@pymodel/pythinker-code": patch
---
Use Shift-Tab in the terminal to cycle the model thinking effort.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
4 changes: 4 additions & 0 deletions .changeset/web-running-tool-emphasis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
"@pymodel/pythinker-code": patch
---
Keep running tool rows at full text emphasis in the web transcript.
6 changes: 3 additions & 3 deletions apps/pythinker-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { AuthFlowController } from '../controllers/auth-flow';
import type { BtwPanelController } from '../controllers/btw-panel';
import type { StreamingUIController } from '../controllers/streaming-ui';
import type { TasksBrowserController } from '../controllers/tasks-browser';
import { tryHandleDanceCommand } from '../easter-eggs/dance';
import { tryHandleHatchCommand } from '../easter-eggs/hatch';
import type { ResolvedTheme } from '../theme/colors';
import type { TUIState } from '../tui-state';
import type {
Expand Down Expand Up @@ -378,10 +378,10 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi
return;
}
case 'message':
// Unknown slash command: let /dance claim it before it falls through to
// Unknown slash command: let /hatch claim it before it falls through to
// the model as a normal message. This runs *after* builtin and skill
// resolution, so a real command or a same-named skill always wins.
if (parsedCommand !== null && tryHandleDanceCommand(host, parsedCommand)) {
if (parsedCommand !== null && tryHandleHatchCommand(host, parsedCommand)) {
return;
}
host.sendNormalUserInput(intent.input);
Expand Down
38 changes: 28 additions & 10 deletions apps/pythinker-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import chalk from 'chalk';
import { effectiveModelAlias } from '@pymodel/pythinker-code-sdk';

import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips';
import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance';
import { isRainbowHatching, renderHatchFooterModel } from '#/tui/easter-eggs/hatch';
import { currentTheme } from '#/tui/theme';
import type { ColorPalette } from '#/tui/theme/colors';
import type { AppState } from '#/tui/types';
Expand Down Expand Up @@ -195,6 +195,7 @@ export class FooterComponent implements Component {
private gitCacheWorkDir: string;
private transientHint: string | null = null;
private warningHint: string | null = null;
private streamSpeedTps: number | null = null;
private goalSnapshotKey: string | null = null;
private goalObservedAtMs = Date.now();
private goalTimer: ReturnType<typeof setInterval> | null = null;
Expand Down Expand Up @@ -245,6 +246,17 @@ export class FooterComponent implements Component {
}
}

/**
* Decode speed of the most recently completed step (tokens/s), shown next
* to the context readout on line 2. `null` hides it (turn end, or a step
* too short to measure — see `computeDecodeTps`).
*/
setStreamSpeed(tps: number | null): void {
if (this.streamSpeedTps === tps) return;
this.streamSpeedTps = tps;
this.onRefresh();
}

/**
* Short-lived hint that replaces the rotating toolbar tips on line 1.
* Used by the exit-confirmation double-tap flow to show "Press Ctrl+C
Expand Down Expand Up @@ -337,28 +349,34 @@ export class FooterComponent implements Component {
}
}

// ── Line 2: hint (bottom-left) + context (right) ──
// ── Line 2: hint (bottom-left) + context + stream speed (right) ──
const contextText = formatContextStatus(
state.contextUsage,
state.contextTokens,
state.maxContextTokens,
);
const contextWidth = visibleWidth(contextText);
const speedSuffix =
this.streamSpeedTps === null ? '' : ` · ${this.streamSpeedTps.toFixed(1)} t/s`;
const rightWidth = visibleWidth(contextText) + visibleWidth(speedSuffix);
let line2: string;
const hint = this.transientHint ?? this.warningHint;
if (hint) {
const maxHintWidth = Math.max(0, width - contextWidth - 1);
const maxHintWidth = Math.max(0, width - rightWidth - 1);
const shownHint =
visibleWidth(hint) <= maxHintWidth ? hint : truncateToWidth(hint, maxHintWidth, '…');
const hintWidth = visibleWidth(shownHint);
const pad = Math.max(0, width - hintWidth - contextWidth);
const pad = Math.max(0, width - hintWidth - rightWidth);
line2 =
chalk.hex(colors.warning).bold(shownHint) +
' '.repeat(pad) +
chalk.hex(colors.text)(contextText);
chalk.hex(colors.text)(contextText) +
chalk.hex(colors.textDim)(speedSuffix);
} else {
const leftPad = Math.max(0, width - contextWidth);
line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText);
const leftPad = Math.max(0, width - rightWidth);
line2 =
' '.repeat(leftPad) +
chalk.hex(colors.text)(contextText) +
chalk.hex(colors.textDim)(speedSuffix);
}

return [truncateToWidth(line1, width), truncateToWidth(line2, width)];
Expand Down Expand Up @@ -413,8 +431,8 @@ export class FooterComponent implements Component {
: '';
const modelLabel = `${model}${thinkingLabel}`;
let renderedModelLabel = chalk.hex(colors.text)(modelLabel);
if (isRainbowDancing()) {
renderedModelLabel = renderDanceFooterModel(modelLabel);
if (isRainbowHatching()) {
renderedModelLabel = renderHatchFooterModel(modelLabel);
}
slots['model'] = [renderedModelLabel];
}
Expand Down
20 changes: 10 additions & 10 deletions apps/pythinker-code/src/tui/components/chrome/welcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import type { Component } from '@pymodel/pi-tui';

import {
isRainbowDancing,
renderDanceWelcomeLogo,
renderDanceWelcomeText,
} from '#/tui/easter-eggs/dance';
isRainbowHatching,
renderHatchWelcomeLogo,
renderHatchWelcomeText,
} from '#/tui/easter-eggs/hatch';
import type { AppState } from '#/tui/types';
import type { GitStatusCache } from '#/utils/git/git-status';

Expand Down Expand Up @@ -45,7 +45,7 @@ export class WelcomeComponent implements Component, WelcomeLogoAnimationHost {
) {
this.state = state;
this.gitCache = createWelcomeGitCache(state.workDir);
if (requestRender !== undefined && welcomeLogoAnimationEnabled() && !isRainbowDancing()) {
if (requestRender !== undefined && welcomeLogoAnimationEnabled() && !isRainbowHatching()) {
this.eyeAnimator = new WelcomeLogoAnimator(this, requestRender);
queueMicrotask(() => this.eyeAnimator?.start());
}
Expand All @@ -70,21 +70,21 @@ export class WelcomeComponent implements Component, WelcomeLogoAnimationHost {

render(width: number): string[] {
const isLoggedOut = !this.state.model;
const copy = isRainbowDancing()
const copy = isRainbowHatching()
? (() => {
const text = buildWelcomeCopyText(isLoggedOut);
return {
head: renderDanceWelcomeText(text.head, 2, true),
strapline: renderDanceWelcomeText(text.strapline, 5),
prompt: renderDanceWelcomeText(text.prompt),
head: renderHatchWelcomeText(text.head, 2, true),
strapline: renderHatchWelcomeText(text.strapline, 5),
prompt: renderHatchWelcomeText(text.prompt),
};
})()
: buildWelcomeCopy(isLoggedOut);
const logoLines = renderPythinkerLogoWithEyes(
this.eyeBlinkState,
this.antennaFrame ?? undefined,
);
const renderedLogo = isRainbowDancing() ? renderDanceWelcomeLogo(logoLines) : logoLines;
const renderedLogo = isRainbowHatching() ? renderHatchWelcomeLogo(logoLines) : logoLines;

return renderWelcomeBanner({
width,
Expand Down
4 changes: 4 additions & 0 deletions apps/pythinker-code/src/tui/constant/hatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** Frame interval for the rainbow flow animation. */
export const HATCH_FRAME_MS = 110;
/** How long the rainbow flows before settling (fading out, or freezing). */
export const HATCH_FLOW_MS = 3000;
4 changes: 2 additions & 2 deletions apps/pythinker-code/src/tui/constant/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const WORKING_TIPS: readonly ToolbarTip[] = [
{ text: 'ctrl-s to add guidance without waiting for the turn to finish', priority: 2, solo: true },
{ text: '/tasks to check progress and status for background tasks', priority: 2 },
{ text: '/init: generate AGENTS.md', priority: 2 },
{ text: 'Try /dance for a hidden Easter egg' },
{ text: 'Try /hatch for a hidden Easter egg' },
{
text: '/plugins: manage plugins — try the "Pythinker Datasource" for reliable financial, economic, and academic data',
solo: true,
Expand All @@ -44,6 +44,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 },
];
115 changes: 93 additions & 22 deletions apps/pythinker-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { readFile } from 'node:fs/promises';

import type { FileMeta, PythinkerHarness, Session } from '@pymodel/pythinker-code-sdk';
import type {
FileMeta,
PythinkerHarness,
Session,
ThinkingEffort,
} from '@pymodel/pythinker-code-sdk';
import { compressImageForModel } from '@pymodel/pythinker-code-sdk';

import {
Expand All @@ -11,6 +16,7 @@ import {
import { parseImageMeta } from '#/utils/image/image-mime';
import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor';

import { segmentsFor } from '../components/dialogs/model-selector';
import {
CTRL_C_HINT,
CTRL_D_HINT,
Expand All @@ -28,7 +34,13 @@ import type {
} from '../utils/image-attachment-store';
import { extractMediaAttachments, imageExtensionForMime } from '../utils/image-placeholder';
import { extractInlineSkillActivations } from '../utils/inline-skill-tokens';
import type { PendingExit, QueuedMessage, SteerInputItem } from '../types';
import { thinkingEffortToConfig } from '../utils/thinking-config';
import type {
AppState,
PendingExit,
QueuedMessage,
SteerInputItem,
} from '../types';
import type { TUIState } from '../tui-state';
import type { BtwPanelController } from './btw-panel';

Expand Down Expand Up @@ -63,6 +75,8 @@ export interface EditorKeyboardHost {
releaseStagingMedia(mediaAttachmentIds: readonly number[]): void;
recallLastQueued(): QueuedMessage | undefined;
showError(msg: string): void;
showNotice(title: string, detail?: string): void;
setAppState(patch: Partial<AppState>): void;
track(event: string, props?: Record<string, unknown>): void;
updateEditorBorderHighlight(text?: string): void;
/** `undefined` means the input cannot be a `/goal` command (clear without measuring). */
Expand All @@ -76,7 +90,6 @@ export interface EditorKeyboardHost {
openUndoSelector(): void;
stop(exitCode?: number): Promise<void>;
ensureSession(): Promise<Session | undefined>;
handlePlanToggle(next: boolean): void;
handleInputModeChange(mode: 'prompt' | 'bash'): void;
clearQueuedMessages(): void;
setExternalEditorRunning(running: boolean): void;
Expand Down Expand Up @@ -256,25 +269,7 @@ 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) {
if (!host.engineV2) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}
// 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();
void this.cycleThinkingEffort();
};

editor.onInputModeChange = (mode) => {
Expand Down Expand Up @@ -532,6 +527,82 @@ export class EditorKeyboardController {
void this.host.session?.cancel();
}

/** Guards Shift-Tab cycling while a setThinking round-trip is still pending. */
private thinkingCycleInFlight = false;

/** Shift-Tab: cycle the thinking effort to the current model's next level (wraps). */
private async cycleThinkingEffort(): Promise<void> {
if (this.thinkingCycleInFlight) return;
const { host } = this;
if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) {
host.showError('Cannot change thinking effort while streaming — press Esc or Ctrl-C first.');
return;
}
const alias = host.state.appState.model;
if (alias.trim().length === 0) {
host.showError(LLM_NOT_SET_MESSAGE);
return;
}
const model = host.state.appState.availableModels[alias];
if (model === undefined) {
host.showError('No model selected. Run /model to select one first.');
return;
}
const levels = segmentsFor(model);
if (levels.length <= 1) {
host.showNotice(`${alias} does not offer selectable thinking effort levels.`);
return;
}
this.thinkingCycleInFlight = true;
try {
const prev = host.state.appState.thinkingEffort;
const currentIndex = levels.indexOf(prev);
// An out-of-list live effort (e.g. a provider-specific value) restarts the
// cycle from the off entry when offered, else from the first level.
const startIndex = currentIndex !== -1 ? currentIndex + 1 : Math.max(0, levels.indexOf('off'));
const next = levels[startIndex % levels.length] ?? levels[0]!;
if (host.session !== undefined) {
try {
await host.session.setThinking(next);
} catch (error) {
host.showError(`Failed to set thinking effort: ${formatErrorMessage(error)}`);
return;
}
} else if (!host.engineV2) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}
// v2 session-less: carry the choice into the first lazy-created session,
// the same way a session-only Alt+S choice is applied on creation.
const patch: Partial<AppState> = { thinkingEffort: next };
if (host.session === undefined) patch.lazySessionThinking = next;
host.setAppState(patch);
host.track('thinking_toggle', { enabled: next !== 'off', effort: next, from: prev });
// No transcript notice: the footer already shows the new level live, and
// rapid cycling would stack a line per keypress in the chat history.
await this.persistDefaultEffort(alias, model, next);
} finally {
this.thinkingCycleInFlight = false;
}
}

/** Best-effort persist of the cycled effort as the config default. */
private async persistDefaultEffort(
alias: string,
model: Parameters<typeof segmentsFor>[0],
effort: ThinkingEffort,
): Promise<void> {
const harness = this.host.harness;
if (harness === undefined || alias !== this.host.state.appState.model) return;
try {
await harness.setConfig({ thinking: thinkingEffortToConfig(effort, model.supportEfforts) });
} catch (error) {
this.host.showError(
`Thinking effort set to ${effort}, but failed to save default: ${formatErrorMessage(error)}`,
);
}
}

private cancelCurrentCompaction(): void {
const session = this.host.session;
if (session === undefined) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import { openUrl } from '#/utils/open-url';
import { currentTheme } from '#/tui/theme';
import type { ColorToken } from '#/tui/theme';
import { errorReportHintLine } from '../constant/feedback';
import { formatStepDebugTiming } from '#/utils/usage/debug-timing';
import { computeDecodeTps, formatStepDebugTiming } from '#/utils/usage/debug-timing';
import { nextTranscriptId } from '../utils/transcript-id';
import type { BtwPanelController } from './btw-panel';
import { isPluginMcpToolName, PluginUpdateNotifier } from './plugin-update-notifier';
Expand Down Expand Up @@ -373,6 +373,8 @@ export class SessionEventHandler {
this.host.handleTurnEnded?.(event);
this.host.streamingUI.flushNow();
this.clearStepRetry();
// The last step's decode speed no longer applies once the turn ends.
this.host.state.footer.setStreamSpeed(null);
if (event.reason === 'cancelled') {
this.markActiveAgentDynamicWorkflowsCancelled();
}
Expand Down Expand Up @@ -436,6 +438,9 @@ export class SessionEventHandler {
this.clearStepRetry();
this.host.noteStepUsage(event.usage);
this.maybeShowDebugTiming(event);
this.host.state.footer.setStreamSpeed(
computeDecodeTps(event.usage?.output, event.llmStreamDurationMs),
);

if (event.providerFinishReason === 'filtered') {
this.host.showNotice(
Expand Down
Loading
Loading