diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index 2863fcee..35f06507 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -9,7 +9,7 @@ description: Use when generating changesets in the pythinker-code repository, in - `@pymodel/pythinker-code`: the CLI -All other `@pymodel/*` packages are treated as internal packages, including `@pymodel/pythinker-code-sdk`, `agent-core`, `kosong`, `kaos`, `pythinker-code-oauth`, `pythinker-telemetry`, and `migration-legacy`. +All other `@pymodel/*` packages are treated as internal packages, including `@pymodel/pythinker-code-sdk`, `agent-core`, `kosong`, `kaos`, `pythinker-code-oauth`, and `pythinker-telemetry`. ## Core Rules diff --git a/.changeset/README.md b/.changeset/README.md index b4d672cf..21a91350 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -22,14 +22,12 @@ All other workspace packages are private internal packages, are not published to - `@pymodel/pythinker-telemetry` - `@pymodel/pythinker-web` - `@pymodel/kosong` -- `@pymodel/migration-legacy` - `@pymodel/protocol` - `@pymodel/server` - `@pymodel/server-e2e` - `@pymodel/dashboard` - `@pymodel/dashboard-server` - `@pymodel/dashboard-web` -- `pythinker-migration-legacy` Version impact from internal dependencies must be judged manually. The published artifacts for CLI and SDK bundle internal workspace packages into the artifact itself; runtime `dependencies` of published packages must not include any `@pymodel/*` internal workspace packages. diff --git a/.changeset/config.json b/.changeset/config.json index de34a553..6ca02f02 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -14,14 +14,12 @@ "@pymodel/pythinker-telemetry", "@pymodel/pythinker-web", "@pymodel/kosong", - "@pymodel/migration-legacy", "@pymodel/protocol", "@pymodel/server", "@pymodel/server-e2e", "@pymodel/dashboard", "@pymodel/dashboard-server", - "@pymodel/dashboard-web", - "pythinker-migration-legacy" + "@pymodel/dashboard-web" ], "snapshot": { "useCalculatedVersion": true, diff --git a/.changeset/dark-sidebar-contrast.md b/.changeset/dark-sidebar-contrast.md new file mode 100644 index 00000000..648216b3 --- /dev/null +++ b/.changeset/dark-sidebar-contrast.md @@ -0,0 +1,6 @@ +--- +"@pymodel/pythinker-code": patch +"@pymodel/pythinker-desktop": patch +--- + +Make the workspace header, session timestamps, and the settings row legible in dark mode on the translucent desktop sidebar. diff --git a/.changeset/desktop-dedicated-update-channel.md b/.changeset/desktop-dedicated-update-channel.md new file mode 100644 index 00000000..92f79f0f --- /dev/null +++ b/.changeset/desktop-dedicated-update-channel.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-desktop": patch +--- + +Publish desktop releases to a dedicated update channel so update checks resolve a desktop build instead of an unrelated release, and fail the release when a packaged build carries no update feed. diff --git a/.changeset/desktop-pin-host-port.md b/.changeset/desktop-pin-host-port.md new file mode 100644 index 00000000..58497f40 --- /dev/null +++ b/.changeset/desktop-pin-host-port.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-desktop": patch +--- + +Pin the Host port so the desktop app reconnects to its own Host, and stop reporting builds that cannot self-update as update errors. diff --git a/.changeset/general-session-mode.md b/.changeset/general-session-mode.md new file mode 100644 index 00000000..86d36743 --- /dev/null +++ b/.changeset/general-session-mode.md @@ -0,0 +1,6 @@ +--- +"@pymodel/agent-core": minor +"@pymodel/protocol": minor +--- + +Add a general non-coding session mode for research and connected tools. diff --git a/.changeset/model-catalog-thinking-metadata.md b/.changeset/model-catalog-thinking-metadata.md new file mode 100644 index 00000000..7679a431 --- /dev/null +++ b/.changeset/model-catalog-thinking-metadata.md @@ -0,0 +1,7 @@ +--- +"@pymodel/protocol": minor +"@pymodel/agent-core": minor +"@pymodel/pythinker-web": minor +--- + +The model catalog now carries per-model thinking-effort levels and the adaptive-thinking flag to API clients. diff --git a/.changeset/remove-legacy-migration.md b/.changeset/remove-legacy-migration.md new file mode 100644 index 00000000..ad23eecf --- /dev/null +++ b/.changeset/remove-legacy-migration.md @@ -0,0 +1,6 @@ +--- +'@pymodel/pythinker-code': minor +--- + +Remove the legacy pythinker-cli migration: the `pythinker migrate` subcommand, the first-launch +migration prompt, and the `[imported]` session badge. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89be2ee3..35c8b5fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,15 +61,15 @@ jobs: id-token: write # Required for NPM Trusted Publishing (OIDC) steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 with: fetch-depth: 0 - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6 with: node-version-file: .nvmrc cache: "pnpm" @@ -93,14 +93,14 @@ jobs: - name: Mint release-bot token id: release-bot - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2 with: app-id: ${{ vars.RELEASE_BOT_APP_ID }} private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} - name: Create Release Pull Request or Publish to npm id: changesets - uses: changesets/action@v1 + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # pinned from v1 with: publish: node scripts/release/changeset-publish-idempotent.mjs version: pnpm run version:release @@ -142,13 +142,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6 with: node-version-file: .nvmrc cache: "pnpm" @@ -196,7 +196,7 @@ jobs: - name: Upload VSIX artifacts if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pinned from v7 with: name: pythinker-code-vsix path: apps/vscode/artifacts/vsix/*.vsix @@ -296,12 +296,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6 with: node-version-file: .nvmrc @@ -318,10 +318,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # pinned from v6 with: node-version-file: .nvmrc @@ -330,7 +330,7 @@ jobs: # Cloning and pushing the tap needs contents and nothing else. - name: Mint tap token id: tap-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2 with: app-id: ${{ vars.RELEASE_BOT_APP_ID }} private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} @@ -388,10 +388,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 - name: Download native artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pinned from v8 with: pattern: pythinker-code-native-* path: dist-native-release diff --git a/apps/dashboard/server/src/lib/session-store.ts b/apps/dashboard/server/src/lib/session-store.ts index 04fde960..9dcfcee0 100644 --- a/apps/dashboard/server/src/lib/session-store.ts +++ b/apps/dashboard/server/src/lib/session-store.ts @@ -46,7 +46,6 @@ export async function readSessionDetail(home: string, sessionId: string): Promis const index = await readSessionIndex(home); const workDir = index.get(sessionId)?.workDir ?? ''; const state = await readState(sessionDir); - if (state.custom['imported_from_pythinker_cli'] === true) return null; const agents = await inventoryAgents(sessionDir, state); return { sessionId, sessionDir, workDir, state, agents }; } @@ -65,8 +64,6 @@ async function tryReadSummary( } throw error; } - if (state.custom['imported_from_pythinker_cli'] === true) return null; - try { const agents = await inventoryAgents(sessionDir, state); const main = agents.find((agent) => agent.agentId === 'main'); diff --git a/apps/dashboard/server/test/lib/session-store.test.ts b/apps/dashboard/server/test/lib/session-store.test.ts index 2bed0aad..3d74eb59 100644 --- a/apps/dashboard/server/test/lib/session-store.test.ts +++ b/apps/dashboard/server/test/lib/session-store.test.ts @@ -65,19 +65,6 @@ describe('session-store', () => { expect(sessions[0]!.workDir).toBe(''); }); - it('skips imported_from_pythinker_cli sessions', async () => { - const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - // mark as imported - const { readFile, writeFile } = await import('node:fs/promises'); - const { join } = await import('node:path'); - const state = JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8')); - state.custom = { imported_from_pythinker_cli: true }; - await writeFile(join(sessionDir, 'state.json'), JSON.stringify(state)); - const sessions = await listSessions(home); - expect(sessions).toHaveLength(0); - }); - it('marks a session incompatible when its wire file cannot be scanned', async () => { const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); cleanup = c; diff --git a/apps/dashboard/web/src/components/state/StateTab.tsx b/apps/dashboard/web/src/components/state/StateTab.tsx index 8cdc2b3e..bf4ed2b5 100644 --- a/apps/dashboard/web/src/components/state/StateTab.tsx +++ b/apps/dashboard/web/src/components/state/StateTab.tsx @@ -17,7 +17,7 @@ interface StateJsonShape { createdAt?: string; updatedAt?: string; agents?: Record; - custom?: Record & { imported_from_pythinker_cli?: boolean }; + custom?: Record; } /** State tab — renders the raw `state.json` blob from session detail. @@ -33,8 +33,6 @@ export function StateTab({ state }: StateTabProps) { const createdMs = parseIso(s.createdAt); const updatedMs = parseIso(s.updatedAt); const agentIds = s.agents !== undefined ? Object.keys(s.agents) : []; - const importedFromPythinkerCli = s.custom?.imported_from_pythinker_cli === true; - return (
@@ -44,14 +42,6 @@ export function StateTab({ state }: StateTabProps) {
- {importedFromPythinkerCli ? ( -
- warning · this session is marked - imported_from_pythinker_cli - and would normally be filtered out of the list. -
- ) : null} - {/* Highlight cards */}
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index adea503d..4d2289c2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -10,6 +10,7 @@ import { ipcMain, Menu, nativeImage, + nativeTheme, session, shell, Tray, @@ -38,6 +39,7 @@ import { quitAndInstallNow, setAutoUpdate, } from './updater' +import { windowAppearanceOptions } from './window-options' import { createDesktopLifecycle, type DesktopLifecycle } from './window-lifecycle' const APP_NAME = 'Pythinker' @@ -192,31 +194,7 @@ async function createMainWindow(): Promise { minWidth: 960, minHeight: 640, show: false, - autoHideMenuBar: true, - frame: process.platform === 'win32', - titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden', - ...(process.platform === 'darwin' ? {} : { - titleBarOverlay: { - color: '#00000000', - symbolColor: '#7f858f', - height: 44, - }, - }), - ...(process.platform === 'darwin' ? { - trafficLightPosition: { x: 16, y: 18 }, - vibrancy: 'sidebar' as const, - visualEffectState: 'followWindow' as const, - } : {}), - // Windows uses an opaque window so theme colors do not blend with desktop wallpaper. - ...(process.platform === 'win32' ? { - backgroundColor: '#0d1117', - hasShadow: true, - roundedCorners: true, - thickFrame: true, - } : { - transparent: true, - backgroundColor: '#00000000', - }), + ...windowAppearanceOptions(process.platform), title: APP_NAME, webPreferences: { contextIsolation: true, @@ -271,6 +249,12 @@ ipcMain.handle('pythinker:update:install', (event) => { assertTrustedSender(event) return quitAndInstallNow() }) +ipcMain.handle('pythinker:theme:set-source', (event, source: unknown) => { + assertTrustedSender(event) + if (source === 'dark' || source === 'light' || source === 'system') { + nativeTheme.themeSource = source + } +}) function createTray(images: TrayImages): void { tray = new Tray(images.idle) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 0c2c2d3c..2395bdd6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -6,6 +6,8 @@ contextBridge.exposeInMainWorld('pythinkerDesktop', { setAutoUpdate: (enabled: boolean) => ipcRenderer.invoke('pythinker:update:set-auto', enabled), checkForUpdates: () => ipcRenderer.invoke('pythinker:update:check'), quitAndInstall: () => ipcRenderer.invoke('pythinker:update:install'), + setThemeSource: (source: 'dark' | 'light' | 'system') => + ipcRenderer.invoke('pythinker:theme:set-source', source), onUpdateState: (cb: (state: unknown) => void) => { const listener = (_event: unknown, state: unknown) => cb(state) ipcRenderer.on('pythinker:update:state', listener) diff --git a/apps/desktop/src/window-options.ts b/apps/desktop/src/window-options.ts new file mode 100644 index 00000000..45280ace --- /dev/null +++ b/apps/desktop/src/window-options.ts @@ -0,0 +1,33 @@ +import type { BrowserWindowConstructorOptions } from 'electron' + +export function windowAppearanceOptions(platform: NodeJS.Platform): BrowserWindowConstructorOptions { + if (platform === 'darwin') { + return { + autoHideMenuBar: true, + titleBarStyle: 'hiddenInset', + trafficLightPosition: { x: 16, y: 16 }, + // A transparent NSWindow is non-opaque with a clear background, which + // removes native corners and shadow. Vibrancy does not require it. + vibrancy: 'sidebar', + visualEffectState: 'followWindow', + } + } + if (platform === 'win32') { + return { + autoHideMenuBar: true, + titleBarStyle: 'hidden', + titleBarOverlay: { color: '#00000000', symbolColor: '#7f858f', height: 44 }, + backgroundColor: '#161616', + hasShadow: true, + roundedCorners: true, + thickFrame: true, + } + } + return { + autoHideMenuBar: true, + frame: false, + titleBarStyle: 'hidden', + titleBarOverlay: { color: '#00000000', symbolColor: '#7f858f', height: 44 }, + backgroundColor: '#161616', + } +} diff --git a/apps/desktop/tests/packaging-config.spec.ts b/apps/desktop/tests/packaging-config.spec.ts index 62c764ce..91b90649 100644 --- a/apps/desktop/tests/packaging-config.spec.ts +++ b/apps/desktop/tests/packaging-config.spec.ts @@ -73,24 +73,6 @@ describe('desktop packaging configuration', () => { }) }) - it('keeps desktop download URLs derived from their published release version', () => { - const siteSource = readFileSync(resolve(repositoryRoot, 'apps/site/src/App.vue'), 'utf8') - const desktopVersionMatch = siteSource.match(/const DESKTOP_VERSION = '([^']+)'/) - - expect(desktopVersionMatch).not.toBeNull() - expect(desktopVersionMatch![1]).not.toBe('') - expect(siteSource).not.toContain('Pythinker-0.1.0-arm64.dmg') - expect(siteSource).not.toContain('Pythinker-0.1.0-x64-Setup.exe') - expect(siteSource).toContain('Pythinker-${DESKTOP_VERSION}-arm64.dmg') - expect(siteSource).toContain('Pythinker-${DESKTOP_VERSION}-x64-Setup.exe') - expect(siteSource).toContain('releases/download/v${DESKTOP_VERSION}') - expect(siteSource).not.toContain('releases/download/v0.1.0') - - const desktopShowcaseMatch = siteSource.match(/
/) - expect(desktopShowcaseMatch).not.toBeNull() - expect(desktopShowcaseMatch![0]).toContain('/brand/windows11.svg') - }) - it('maps the staged Host node_modules directory as the copy root', () => { expect(desktopPackage.build.extraResources).toEqual(expect.arrayContaining([ { from: 'resources', to: 'desktop-resources' }, diff --git a/apps/desktop/tests/window-appearance.spec.ts b/apps/desktop/tests/window-appearance.spec.ts index 0a3b5fc9..3eaf9e4d 100644 --- a/apps/desktop/tests/window-appearance.spec.ts +++ b/apps/desktop/tests/window-appearance.spec.ts @@ -1,43 +1,42 @@ // Static check: no Windows host exists in CI or locally, so this test guards the // window configuration rather than the rendered result. -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' - -const desktopRoot = resolve(import.meta.dirname, '..') -const mainSource = readFileSync(resolve(desktopRoot, 'src', 'main.ts'), 'utf8') +import { windowAppearanceOptions } from '../src/window-options' describe('desktop window appearance configuration', () => { - it('keeps Windows opaque and non-Windows windows transparent', () => { - const backgroundMaterialMatches = [...mainSource.matchAll(/backgroundMaterial/gu)] - const win32BranchMatches = [...mainSource.matchAll( - /\.\.\.\(process\.platform === 'win32' \? \{([\s\S]*?)\} : \{\s*transparent: true,/gu, - )] - const nonWin32BranchMatches = [...mainSource.matchAll( - /\} : \{\s*transparent: true,[\s\S]*?\}\),\s*title:/gu, - )] + it('uses the native macOS frame with vibrancy', () => { + const opts = windowAppearanceOptions('darwin') - expect(backgroundMaterialMatches).toHaveLength(0) - expect(win32BranchMatches).toHaveLength(1) - expect(nonWin32BranchMatches).toHaveLength(1) + expect('frame' in opts).toBe(false) + expect(opts['titleBarStyle']).toBe('hiddenInset') + expect(opts['trafficLightPosition']).toEqual({ x: 16, y: 16 }) + // Native corners and shadow require an opaque window. + expect('transparent' in opts).toBe(false) + expect('backgroundColor' in opts).toBe(false) + expect(opts['vibrancy']).toBe('sidebar') + }) - const win32Branch = win32BranchMatches[0]![1]! - const opaqueColorMatches = [...win32Branch.matchAll(/backgroundColor:\s*'#[0-9a-fA-F]{6}'/gu)] - const alphaColorMatches = [...win32Branch.matchAll(/#[0-9a-fA-F]{8}/gu)] - const hasShadowMatches = [...win32Branch.matchAll(/hasShadow:\s*true/gu)] - const roundedCornersMatches = [...win32Branch.matchAll(/roundedCorners:\s*true/gu)] - const thickFrameMatches = [...win32Branch.matchAll(/thickFrame:\s*true/gu)] + it('keeps the Windows window configuration unchanged', () => { + const opts = windowAppearanceOptions('win32') - expect(opaqueColorMatches).toHaveLength(1) - expect(alphaColorMatches).toHaveLength(0) - expect(hasShadowMatches).toHaveLength(1) - expect(roundedCornersMatches).toHaveLength(1) - expect(thickFrameMatches).toHaveLength(1) + expect(opts).toMatchObject({ + autoHideMenuBar: true, + titleBarStyle: 'hidden', + titleBarOverlay: { color: '#00000000', symbolColor: '#7f858f', height: 44 }, + backgroundColor: '#161616', + hasShadow: true, + roundedCorners: true, + thickFrame: true, + }) + }) - expect(win32Branch).toContain('backgroundColor') - expect(nonWin32BranchMatches[0]![0]).toContain('transparent: true') - expect(win32Branch).toContain('hasShadow') - expect(win32Branch).toContain('roundedCorners') - expect(win32Branch).toContain('thickFrame') + it('keeps the Linux window configuration unchanged', () => { + expect(windowAppearanceOptions('linux')).toEqual({ + autoHideMenuBar: true, + frame: false, + titleBarStyle: 'hidden', + titleBarOverlay: { color: '#00000000', symbolColor: '#7f858f', height: 44 }, + backgroundColor: '#161616', + }) }) }) diff --git a/apps/pythinker-code/package.json b/apps/pythinker-code/package.json index 8c1519b8..b700f7d0 100644 --- a/apps/pythinker-code/package.json +++ b/apps/pythinker-code/package.json @@ -100,7 +100,6 @@ "@pymodel/acp-adapter": "workspace:^", "@pymodel/dashboard-server": "workspace:^", "@pymodel/dashboard-web": "workspace:*", - "@pymodel/migration-legacy": "workspace:^", "@pymodel/pythinker-code-oauth": "workspace:^", "@pymodel/pythinker-code-sdk": "workspace:^", "@pymodel/pythinker-telemetry": "workspace:^", diff --git a/apps/pythinker-code/src/cli/commands.ts b/apps/pythinker-code/src/cli/commands.ts index 704a420d..7e45e83d 100644 --- a/apps/pythinker-code/src/cli/commands.ts +++ b/apps/pythinker-code/src/cli/commands.ts @@ -1,5 +1,4 @@ import { CLI_COMMAND_NAME } from '#/constant/app'; -import { registerMigrateCommand } from '#/migration/index'; import { Command, Option } from 'commander'; import type { CLIOptions } from './options'; @@ -13,14 +12,12 @@ import { registerServerCommand } from './sub/server'; import { registerDashboardCommand } from './sub/dashboard'; export type MainCommandHandler = (opts: CLIOptions) => void; -export type MigrateCommandHandler = () => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; export type UpgradeCommandHandler = () => void | Promise; export function createProgram( version: string, onMain: MainCommandHandler, - onMigrate: MigrateCommandHandler, onPluginNodeRunner: PluginNodeRunnerHandler = () => {}, onUpgrade: UpgradeCommandHandler = () => {}, ): Command { @@ -107,7 +104,6 @@ export function createProgram( registerLoginCommand(program); registerDoctorCommand(program); registerDashboardCommand(program); - registerMigrateCommand(program, onMigrate); program .command('upgrade') .alias('update') diff --git a/apps/pythinker-code/src/cli/run-shell.ts b/apps/pythinker-code/src/cli/run-shell.ts index 8a4d9f9f..c5dc125c 100644 --- a/apps/pythinker-code/src/cli/run-shell.ts +++ b/apps/pythinker-code/src/cli/run-shell.ts @@ -1,6 +1,4 @@ import { execSync } from 'node:child_process'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; import { createPythinkerHarness, @@ -17,7 +15,6 @@ import { } from '@pymodel/pythinker-telemetry'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; -import { detectPendingMigration } from '#/migration/index'; import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; @@ -34,7 +31,6 @@ import { createPythinkerCodeHostIdentity } from './version'; export async function runShell( opts: CLIOptions, version: string, - runOptions: { readonly migrateOnly?: boolean } = {}, ): Promise { const startedAt = Date.now(); const configStartedAt = startedAt; @@ -86,16 +82,6 @@ export async function runShell( } return; } - const migrationPlan = await detectPendingMigration({ - sourceHome: join(homedir(), '.pythinker'), - targetHome: harness.homeDir, - ignoreMarker: runOptions.migrateOnly, - }); - if (runOptions.migrateOnly === true && migrationPlan === null) { - process.stdout.write(' Nothing to migrate from ~/.pythinker/.\n'); - await harness.close(); - return; - } const config = await harness.getConfig(); for (const warning of (await harness.getConfigDiagnostics()).warnings) { configWarning = combineStartupNotice(configWarning, warning); @@ -107,8 +93,6 @@ export async function runShell( version, workDir, startupNotice: configWarning, - migrationPlan, - migrateOnly: runOptions.migrateOnly, }); initializeCliTelemetry({ diff --git a/apps/pythinker-code/src/main.ts b/apps/pythinker-code/src/main.ts index 0db0206e..bae15388 100644 --- a/apps/pythinker-code/src/main.ts +++ b/apps/pythinker-code/src/main.ts @@ -107,11 +107,6 @@ export async function handleMainCommand(opts: CLIOptions, version: string): Prom await runShell(validated.options, version); } -/** `pythinker migrate`: launch the migration screen only, then exit. */ -async function handleMigrateCommand(version: string): Promise { - await runShell(MIGRATE_CLI_OPTIONS, version, { migrateOnly: true }); -} - export async function handleUpgradeCommand(version: string): Promise { const telemetryBootstrap = createCliTelemetryBootstrap(); const telemetryClient: TelemetryClient = { @@ -145,24 +140,6 @@ export async function handleUpgradeCommand(version: string): Promise { process.exit(exitCode); } -/** A neutral CLIOptions value — `pythinker migrate` never opens a chat session. */ -const MIGRATE_CLI_OPTIONS: CLIOptions = { - session: undefined, - continue: false, - rewindFiles: undefined, - yolo: false, - auto: false, - init: false, - initOnly: false, - maintenance: false, - plan: false, - model: undefined, - outputFormat: undefined, - prompt: undefined, - skillsDirs: [], - additionalDirs: [], -}; - export function main(): void { process.title = PROCESS_NAME; installCrashHandlers(); @@ -206,17 +183,6 @@ export function main(): void { process.exit(1); }); }, - () => { - void handleMigrateCommand(version).catch(async (error: unknown) => { - await logStartupFailure('run migration', error); - await writeAndDrain( - process.stderr, - formatStartupError(error, { operation: 'run migration' }) - + `See log: ${resolveGlobalLogPath(resolvePythinkerHome())}\n`, - ); - process.exit(1); - }); - }, (entry, args) => { void runPluginNodeEntry(entry, args).catch(async (error: unknown) => { await logStartupFailure('run plugin node entry', error); diff --git a/apps/pythinker-code/src/migration/badge.ts b/apps/pythinker-code/src/migration/badge.ts deleted file mode 100644 index 43399979..00000000 --- a/apps/pythinker-code/src/migration/badge.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Pure helpers for composing session labels in the session picker. - * - * Detection rule for the `[imported]` badge: `metadata.imported_from_pythinker_cli` - * is strictly the boolean `true`. This mirrors the value written by - * `migration-legacy` into the session's `state.json` `custom` block. - */ - -const IMPORTED_BADGE = '[imported]'; -const IMPORTED_FLAG_KEY = 'imported_from_pythinker_cli'; - -export interface SessionLabelInput { - readonly title: string; - readonly metadata?: Readonly> | undefined; -} - -export function isImportedSession( - metadata: Readonly> | undefined, -): boolean { - if (metadata === undefined) return false; - return metadata[IMPORTED_FLAG_KEY] === true; -} - -export function formatSessionLabel(input: SessionLabelInput): string { - const prefix = isImportedSession(input.metadata) ? `${IMPORTED_BADGE} ` : ''; - return `${prefix}${input.title}`; -} diff --git a/apps/pythinker-code/src/migration/command.ts b/apps/pythinker-code/src/migration/command.ts deleted file mode 100644 index a55ae1db..00000000 --- a/apps/pythinker-code/src/migration/command.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * `pythinker migrate` sub-command. - * - * A bare, flagless subcommand: it launches the native pi-tui migration screen - * (the same one shown on first launch), then exits. The screen collects the - * migration scope interactively, so there are no CLI options. The actual - * launch is delegated to a host-provided handler. - */ - -import type { Command } from 'commander'; - -export function registerMigrateCommand(parent: Command, onMigrate: () => void): void { - parent - .command('migrate') - .description('Migrate data from a legacy pythinker-cli installation into pythinker-code.') - .action(() => { - onMigrate(); - }); -} diff --git a/apps/pythinker-code/src/migration/detect-pending.ts b/apps/pythinker-code/src/migration/detect-pending.ts deleted file mode 100644 index 58cec5e0..00000000 --- a/apps/pythinker-code/src/migration/detect-pending.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Pre-TUI detection: decide whether a first-launch migration screen should be - * shown. Cheap, synchronous-ish, no TTY required. Returns the MigrationPlan to - * drive the screen, or null when there is nothing to offer. - */ -import { existsSync, readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; - -import { detectMigration, type MigrationPlan } from '@pymodel/migration-legacy'; - -export interface DetectPendingInput { - readonly sourceHome: string; - readonly targetHome: string; - /** - * When true, skip the marker-based suppression (`.migrated-to-pythinker-code` / - * `.skip-migration-from-pythinker-cli`). The explicit `pythinker migrate` command sets - * this so a deliberate invocation always runs regardless of prior runs. - */ - readonly ignoreMarker?: boolean; -} - -export async function detectPendingMigration( - input: DetectPendingInput, -): Promise { - const { sourceHome, targetHome } = input; - if (!existsSync(sourceHome)) return null; - if (input.ignoreMarker !== true) { - if (migrationAlreadyTargeted(join(sourceHome, '.migrated-to-pythinker-code'), targetHome)) { - return null; - } - if (existsSync(join(targetHome, '.skip-migration-from-pythinker-cli'))) return null; - } - - let plan: MigrationPlan; - try { - plan = await detectMigration({ sourcePath: sourceHome }); - } catch { - // Detection failure must never block startup; skip the screen. - return null; - } - - // OAuth credentials are deliberately not migrated, so an install whose - // only data is `credentials/*.json` has nothing to offer — pythinker-code's own - // /login flow will pick up the auth conversation when the user first uses - // the app. Treat oauth-only as "nothing to migrate". - const nothingToMigrate = - plan.totalSessions === 0 && - !plan.hasConfig && - !plan.hasMcp && - !plan.hasUserHistory; - if (nothingToMigrate) return null; - - return plan; -} - -/** - * True when the legacy `.migrated-to-pythinker-code` marker records a migration - * into *this* target home. A marker written for a different `PYTHINKER_CODE_HOME` - * must not suppress the prompt — that target has never received migrated data. - * An unreadable/old marker without `target_path` is treated as "matches" - * (conservative: do not re-prompt when the marker exists but is ambiguous). - */ -function migrationAlreadyTargeted(markerPath: string, targetHome: string): boolean { - if (!existsSync(markerPath)) return false; - try { - const parsed = JSON.parse(readFileSync(markerPath, 'utf-8')) as { target_path?: unknown }; - if (typeof parsed.target_path !== 'string') return true; - return resolve(parsed.target_path) === resolve(targetHome); - } catch { - return true; - } -} diff --git a/apps/pythinker-code/src/migration/index.ts b/apps/pythinker-code/src/migration/index.ts deleted file mode 100644 index 565b77c7..00000000 --- a/apps/pythinker-code/src/migration/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * pythinker-cli → pythinker-code migration: host integration surface. - * - * Removable glue: the `pythinker migrate` sub-command, the first-launch detection, - * the native pi-tui migration screen, and the session-picker `[imported]` - * badge helper. Migration logic itself lives in - * `@pymodel/migration-legacy`. - */ -export { registerMigrateCommand } from './command'; -export { formatSessionLabel, isImportedSession, type SessionLabelInput } from './badge'; -export { detectPendingMigration } from './detect-pending'; -export { MigrationScreenComponent, type MigrationScreenResult } from './migration-screen'; diff --git a/apps/pythinker-code/src/migration/migration-screen.ts b/apps/pythinker-code/src/migration/migration-screen.ts deleted file mode 100644 index afa0d45d..00000000 --- a/apps/pythinker-code/src/migration/migration-screen.ts +++ /dev/null @@ -1,550 +0,0 @@ -/** - * MigrationScreenComponent — native pi-tui first-launch migration experience. - * - * A single mounted Container & Focusable that runs a 3-phase state machine: - * ask (2-step choice wizard) -> progress -> result - * - * Pure decision mapping (choices -> MigrationScope) is delegated to the - * package's `resolveMigrationScope`. Rendering follows the `ChoicePicker` - * conventions in `apps/pythinker-code/src/tui/components/dialogs/choice-picker.ts`. - * - * This file implements the ask, progress, and result phases. `beginMigration` - * drives the real runMigration flow (injectable for tests). - */ -import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import type { ColorPalette } from '#/tui/theme/colors'; -import { currentTheme } from '#/tui/theme'; -import { - resolveMigrationScope, - runMigration as realRunMigration, - type AnyChoice, - type MigrationPlan, - type MigrationPromptResult, - type MigrationReport, - type MigrationScope, - type Prompt1Choice, - type Prompt2Choice, - type RunMigrationInput, -} from '@pymodel/migration-legacy'; - -type Phase = 'ask1' | 'ask2' | 'progress' | 'result'; - -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const; - -/** Spinner frame cadence — one full braille cycle every ~800ms. */ -const SPINNER_INTERVAL_MS = 80; - -const STEP_LABELS: ReadonlyArray = [ - ['config', 'Config'], - ['mcp', 'MCP'], - ['user-history', 'REPL history'], - ['sessions', 'Sessions'], -]; - -export interface MigrationScreenOptions { - readonly plan: MigrationPlan; - readonly sourceHome: string; - readonly targetHome: string; - readonly colors?: ColorPalette; - /** Called once the screen is finished; the host then restores the editor. */ - readonly onComplete: (result: MigrationScreenResult) => void; - /** Triggers a re-render; the host wires this to `ui.requestRender()`. */ - readonly requestRender?: () => void; - /** Injectable for tests; defaults to the package's runMigration. */ - readonly runMigration?: (input: RunMigrationInput) => Promise; - /** - * When true, the screen starts at the scope question and skips the - * now/later/never gate — used by the explicit `pythinker migrate` command, where - * invoking the command is itself the decision to migrate. - */ - readonly skipDecisionStep?: boolean; -} - -/** What the screen reports back to the host when finished. */ -export interface MigrationScreenResult { - readonly decision: 'now' | 'later' | 'never'; - /** Resolved migration scope; present only when decision === 'now'. */ - readonly scope?: MigrationScope; - // present only when decision === 'now' and migration ran - readonly migrated?: boolean; -} - -interface StepDef { - readonly title: string; - readonly options: ReadonlyArray<{ readonly label: string; readonly value: AnyChoice }>; -} - -export class MigrationScreenComponent extends Container implements Focusable { - focused = false; - private readonly opts: MigrationScreenOptions; - private phase: Phase = 'ask1'; - private selectedIndex = 0; - private readonly choices: AnyChoice[] = []; - private progressDone = 0; - private progressTotal = 0; - private readonly stepStatus = new Map([ - ['config', 'pending'], - ['mcp', 'pending'], - ['user-history', 'pending'], - ['sessions', 'pending'], - ]); - private spinnerFrame = 0; - private spinnerTimer: ReturnType | undefined; - private report: MigrationReport | undefined; - private migrationFailed = false; - private migrationFailureReason: string | undefined; - - constructor(opts: MigrationScreenOptions) { - super(); - this.opts = opts; - if (opts.skipDecisionStep === true) { - // Explicit `pythinker migrate`: the now/later/never gate is meaningless, so - // start at the scope question with the decision already fixed to 'now'. - this.phase = 'ask2'; - this.choices.push('now'); - } - } - - /** Host calls this once runMigration resolves. */ - showResult(report: MigrationReport): void { - this.report = report; - this.phase = 'result'; - this.stopSpinner(); - } - - /** Host calls this if runMigration threw. */ - showFailure(error?: unknown): void { - this.migrationFailed = true; - this.migrationFailureReason = formatMigrationFailureReason(error); - this.phase = 'result'; - this.stopSpinner(); - } - - /** Host calls this when migration starts. */ - enterProgress(): void { - this.phase = 'progress'; - } - - /** Host wires this to runMigration's onProgress (step-level messages). */ - reportStep(msg: string): void { - // msg is like 'config done', 'mcp done', 'sessions done' - const key = msg.replace(/ done$/, ''); - if (this.stepStatus.has(key)) this.stepStatus.set(key, 'done'); - } - - /** Host wires this to runMigration's onSessionProgress. */ - reportSessionProgress(done: number, total: number): void { - this.progressDone = done; - this.progressTotal = total; - } - - // The braille spinner advances on its own timer so the progress screen stays - // visibly alive even while a single step (e.g. session translation) runs for - // a while without emitting progress events. Runs only for the progress - // phase: started on entering it, stopped the moment it ends. - private startSpinner(): void { - this.stopSpinner(); - this.spinnerTimer = setInterval(() => { - this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length; - this.opts.requestRender?.(); - }, SPINNER_INTERVAL_MS); - // A decorative timer must never keep the process alive on its own. - this.spinnerTimer.unref(); - } - - private stopSpinner(): void { - if (this.spinnerTimer !== undefined) { - clearInterval(this.spinnerTimer); - this.spinnerTimer = undefined; - } - } - - // test hooks (thin aliases so tests don't depend on host wiring) - _testEnterProgress(): void { - this.enterProgress(); - } - _testUpdateStep(msg: string): void { - this.reportStep(msg); - } - _testUpdateSessionProgress(done: number, total: number): void { - this.reportSessionProgress(done, total); - } - _testShowResult(report: MigrationReport): void { - this.showResult(report); - } - - handleInput(data: string): void { - if (this.phase === 'ask1' || this.phase === 'ask2') { - this.handleAskInput(data); - return; - } - if (this.phase === 'result') { - if (matchesKey(data, Key.enter)) { - this.opts.onComplete({ decision: 'now', migrated: !this.migrationFailed }); - } - return; - } - // progress phase: ignore input - } - - private currentStep(): StepDef { - return stepFor(this.phase, this.opts.plan); - } - - private handleAskInput(data: string): void { - const step = this.currentStep(); - if (matchesKey(data, Key.up)) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - return; - } - if (matchesKey(data, Key.down)) { - this.selectedIndex = Math.min(step.options.length - 1, this.selectedIndex + 1); - return; - } - if (matchesKey(data, Key.escape)) { - // Esc anywhere in ask == "later" - this.opts.onComplete({ decision: 'later' }); - return; - } - if (matchesKey(data, Key.enter)) { - const chosen = step.options[this.selectedIndex]; - if (chosen === undefined) return; - this.advance(chosen.value); - return; - } - } - - /** Apply a chosen value and move the state machine forward. */ - private advance(value: AnyChoice): void { - this.choices.push(value); - this.selectedIndex = 0; - - const result: MigrationPromptResult = resolveMigrationScope(this.choices); - if (this.phase === 'ask1') { - if (value === 'now') { - this.phase = 'ask2'; - return; - } - // 'later' | 'never' - this.opts.onComplete({ decision: value as 'later' | 'never' }); - return; - } - // ask2 — either choice resolves the full scope; run migration immediately. - this.beginMigration(result); - } - - /** Enter the progress phase and run the migration to completion. */ - private beginMigration(result: MigrationPromptResult): void { - if (result.decision !== 'now' || result.scope === undefined) { - this.opts.onComplete({ decision: 'later' }); - return; - } - this.enterProgress(); - this.startSpinner(); - this.opts.requestRender?.(); - const run = this.opts.runMigration ?? realRunMigration; - void run({ - plan: this.opts.plan, - scope: result.scope, - source: this.opts.sourceHome, - target: this.opts.targetHome, - onProgress: (msg) => { - this.reportStep(msg); - this.opts.requestRender?.(); - }, - onSessionProgress: (done, total) => { - this.reportSessionProgress(done, total); - this.opts.requestRender?.(); - }, - }).then( - (report) => { - this.showResult(report); - this.opts.requestRender?.(); - }, - (error) => { - this.showFailure(error); - this.opts.requestRender?.(); - }, - ); - } - - override render(width: number): string[] { - if (this.phase === 'ask1' || this.phase === 'ask2') { - return this.renderAsk(width); - } - if (this.phase === 'progress') return this.renderProgress(width); - return this.renderResult(width); - } - - private renderResult(width: number): string[] { - const colors = this.opts.colors ?? currentTheme.palette; - const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))]; - if (this.migrationFailed) { - lines.push(chalk.hex(colors.error).bold(' Migration failed')); - if (this.migrationFailureReason !== undefined) { - lines.push('', chalk.hex(colors.text)(` Reason: ${this.migrationFailureReason}`)); - } - lines.push('', chalk.hex(colors.text)(' You can retry later by running "pythinker migrate".'), '', chalk.hex(colors.textMuted)(' ⏎ continue to pythinker-code'), chalk.hex(colors.primary)('─'.repeat(width))); - return lines.map((l) => truncateToWidth(l, width)); - } - const r = this.report; - lines.push(chalk.hex(colors.primary).bold(' Migration complete'), ''); - if (r !== undefined) { - const sum = r.summary; - if (sum.sessions.sessionsMigrated > 0) { - lines.push( - chalk.hex(colors.success)(` ✓ ${sum.sessions.sessionsMigrated} sessions migrated`), - ); - } - // Only claim a data class was migrated when the summary says it was — - // a skipped/failed step (e.g. malformed config.toml) must not show ✓. - const migratedKinds: string[] = []; - if (sum.config.migrated) migratedKinds.push('config'); - if (sum.config.migratedHooks > 0) migratedKinds.push('hooks'); - if (sum.mcp.mergedServers.length > 0) migratedKinds.push('MCP'); - if (sum.userHistory.copied > 0) migratedKinds.push('REPL history'); - if (sum.skills.copied > 0) migratedKinds.push('skills'); - if (migratedKinds.length > 0) { - lines.push(chalk.hex(colors.success)(` ✓ ${migratedKinds.join(' · ')}`)); - } - if (sum.sessions.sessionsMigrated === 0 && migratedKinds.length === 0) { - lines.push(chalk.hex(colors.textMuted)(' Nothing needed migrating.')); - } - if (r.notices.detectedPlugins.length > 0) { - lines.push( - chalk.hex(colors.warning)( - ` ⚠ ${r.notices.detectedPlugins.length} pythinker-cli plugins — not yet supported for migration`, - ), - ); - } - // OAuth credentials are deliberately not migrated (refresh tokens cannot - // safely be held by two installs at once). pythinker-code's normal auth flow - // will prompt for /login when the user first picks a model — surfacing a - // separate notice here reads as a migration limitation, which it is not. - if (sum.config.droppedHooks > 0) { - lines.push( - chalk.hex(colors.warning)( - ` ⚠ ${sum.config.droppedHooks} hooks dropped (incompatible)`, - ), - ); - } - // Conflicts and partial failures: the report records them, so surface - // them here too — otherwise "✓ config / MCP" hides that the data only - // landed in a *.migrated-from-pythinker-cli.* sibling or that sessions failed. - if (sum.config.configConflicts.length > 0) { - lines.push( - chalk.hex(colors.warning)( - ` ⚠ ${sum.config.configConflicts.length} config conflicts kept yours: ${sum.config.configConflicts.join(' · ')}`, - ), - ); - } - if (sum.config.wroteSiblingDueToConflict) { - // Sibling mode: the live config.toml could not be parsed, so the - // migrated content went to `config.migrated-from-pythinker-cli.toml` and - // the user must merge it by hand. Show the enumeration of contents - // on a SEPARATE line below — a single-line message with the contents - // appended would overflow 80 columns and be truncated, silently - // hiding the very info we want users to see. - lines.push( - chalk.hex(colors.warning)( - ' ⚠ config.toml could not be parsed — review config.migrated-from-pythinker-cli.toml', - ), - ); - const sc = sum.config.siblingContents; - const items: string[] = []; - if (sc.providers.length > 0) { - items.push(`${sc.providers.length} provider${sc.providers.length === 1 ? '' : 's'}`); - } - if (sc.models.length > 0) { - items.push(`${sc.models.length} model${sc.models.length === 1 ? '' : 's'}`); - } - if (sc.hooks > 0) { - items.push(`${sc.hooks} hook${sc.hooks === 1 ? '' : 's'}`); - } - if (items.length > 0) { - lines.push(chalk.hex(colors.warning)(` contains: ${items.join(', ')}`)); - } - } - if (sum.config.wroteTuiSibling) { - lines.push( - chalk.hex(colors.warning)( - ' ⚠ tui.toml conflicted — review tui.migrated-from-pythinker-cli.toml', - ), - ); - } - if (sum.mcp.wroteSiblingDueToConflict) { - lines.push( - chalk.hex(colors.warning)( - ' ⚠ mcp.json unreadable — review mcp.migrated-from-pythinker-cli.json', - ), - ); - } - if (r.notices.mcpOauthServersRequiringReauth.length > 0) { - lines.push( - chalk.hex(colors.warning)( - ` ⚠ ${r.notices.mcpOauthServersRequiringReauth.length} MCP servers need re-authentication`, - ), - ); - } - if (sum.sessions.sessionsFailed.length > 0) { - lines.push( - chalk.hex(colors.warning)( - ` ⚠ ${sum.sessions.sessionsFailed.length} sessions failed to migrate`, - ), - ); - } - if (sum.sessions.sessionsConflicts.length > 0) { - lines.push( - chalk.hex(colors.warning)( - ` ⚠ ${sum.sessions.sessionsConflicts.length} sessions skipped (target already occupied)`, - ), - ); - } - // Empty / user-cleared sessions carry no conversation — neutral info, - // not a failure, so it is shown muted rather than as a ⚠ warning. - if (sum.sessions.sessionsSkippedEmpty > 0) { - lines.push( - chalk.hex(colors.textMuted)( - ` ${sum.sessions.sessionsSkippedEmpty} empty sessions skipped`, - ), - ); - } - lines.push('', chalk.hex(colors.textMuted)(' Old data kept at ~/.pythinker/ — pythinker-cli still works.')); - } - lines.push('', chalk.hex(colors.textMuted)(' ⏎ continue to pythinker-code'), chalk.hex(colors.primary)('─'.repeat(width))); - return lines.map((l) => truncateToWidth(l, width)); - } - - private renderProgress(width: number): string[] { - const colors = this.opts.colors ?? currentTheme.palette; - const spinner = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0]; - const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Migrating from pythinker-cli'), - '', - ]; - if (this.progressTotal > 0) { - lines.push( - chalk.hex(colors.accent)(` ${spinner} `) + - chalk.hex(colors.text)( - `Translating sessions… ${this.progressDone} / ${this.progressTotal}`, - ), - ''); - } - for (const [key, label] of STEP_LABELS) { - const status = this.stepStatus.get(key) ?? 'pending'; - const mark = - status === 'done' - ? chalk.hex(colors.success)('✓') - : chalk.hex(colors.textDim)('◐'); - lines.push(` ${mark} ${chalk.hex(colors.text)(label)}`); - } - lines.push('', chalk.hex(colors.primary)('─'.repeat(width))); - return lines.map((l) => truncateToWidth(l, width)); - } - - private renderAsk(width: number): string[] { - const colors = this.opts.colors ?? currentTheme.palette; - const step = this.currentStep(); - const lines: string[] = [ - chalk.hex(colors.primary)('─'.repeat(width)), - chalk.hex(colors.primary).bold(' Migrate from pythinker-cli'), - '', - ]; - if (this.phase === 'ask1') { - lines.push(chalk.hex(colors.text)(' Found an existing pythinker-cli installation:'), chalk.hex(colors.textMuted)(` ${summarizePlan(this.opts.plan)}`), ''); - } - lines.push(chalk.hex(colors.text)(` ${step.title}`), ''); - for (let i = 0; i < step.options.length; i++) { - const opt = step.options[i]!; - const isSel = i === this.selectedIndex; - const pointer = isSel ? '❯' : ' '; - const labelStyle = isSel ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); - lines.push( - chalk.hex(isSel ? colors.primary : colors.textDim)(` ${pointer} `) + - labelStyle(opt.label), - ); - } - lines.push('', chalk.hex(colors.textMuted)( - ` ↑/↓ move · ⏎ select · esc ${this.opts.skipDecisionStep === true ? 'cancel' : 'later'}`, - ), chalk.hex(colors.primary)('─'.repeat(width))); - return lines.map((l) => truncateToWidth(l, width)); - } -} - -function formatMigrationFailureReason(error: unknown): string | undefined { - let reason: string | undefined; - if (error instanceof Error) { - reason = error.message !== '' ? error.message : error.name; - } else if (typeof error === 'string') { - reason = error; - } else if (typeof error === 'object' && error !== null) { - const maybeMessage = (error as { readonly message?: unknown }).message; - if (typeof maybeMessage === 'string' && maybeMessage !== '') { - reason = maybeMessage; - } - } - if (reason === undefined) { - switch (typeof error) { - case 'number': - case 'boolean': - case 'bigint': - reason = `${error}`; - break; - case 'symbol': - reason = - error.description !== undefined ? `Symbol(${error.description})` : 'Symbol rejection'; - break; - case 'function': - reason = error.name !== '' ? `Function ${error.name}` : 'Function rejection'; - break; - case 'object': - if (error !== null) reason = 'Object rejection'; - break; - case 'undefined': - break; - case 'string': - break; - } - } - const trimmed = reason?.trim(); - return trimmed === undefined || trimmed === '' ? undefined : trimmed; -} - -function summarizePlan(plan: MigrationPlan): string { - const parts: string[] = []; - if (plan.totalSessions > 0) parts.push(`${plan.totalSessions} sessions`); - if (plan.hasConfig) parts.push('config.toml'); - if (plan.hasMcp) parts.push('mcp.json'); - if (plan.hasUserHistory) parts.push('REPL history'); - return parts.join(' · '); -} - -function stepFor(phase: Phase, plan: MigrationPlan): StepDef { - if (phase === 'ask1') { - return { - title: 'Migrate this data to pythinker-code?', - options: [ - { label: 'Migrate now', value: 'now' satisfies Prompt1Choice }, - { label: 'Ask me later', value: 'later' satisfies Prompt1Choice }, - { label: 'Never ask again', value: 'never' satisfies Prompt1Choice }, - ], - }; - } - // ask2 — the second option carries the actual session count so users can see - // the cost they are signing up for. Falls back to the singular "sessions" - // word only (no count) when no sessions were detected. - const sessionsLabel = - plan.totalSessions > 0 - ? `Config + ${plan.totalSessions} sessions` - : 'Config + all sessions'; - return { - title: 'Migrate chat sessions too? (they are bulky and slower)', - options: [ - { label: 'Config only', value: 'config-only' satisfies Prompt2Choice }, - { label: sessionsLabel, value: 'all-sessions' satisfies Prompt2Choice }, - ], - }; -} diff --git a/apps/pythinker-code/src/tui/components/dialogs/session-picker.ts b/apps/pythinker-code/src/tui/components/dialogs/session-picker.ts index 932d378f..66128e38 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/session-picker.ts @@ -10,7 +10,6 @@ import { visibleWidth, type Focusable, } from '@earendil-works/pi-tui'; -import { formatSessionLabel } from '#/migration/index'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import { combinedBindingHint, @@ -266,8 +265,7 @@ export class SessionPickerComponent extends Container implements Focusable { const hintParts = [scopeHint, cancel === undefined ? undefined : `${formatBindingKeys(cancel)} cancel`].filter( (item): item is string => item !== undefined, ); - lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS)), currentTheme.fg('textMuted', truncateToWidth(hintParts.join(' · '), width, ELLIPSIS)), '', currentTheme.fg('textMuted', truncateToWidth('No sessions found.', width, ELLIPSIS))); - lines.push(currentTheme.fg('primary', '─'.repeat(width))); + lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS)), currentTheme.fg('textMuted', truncateToWidth(hintParts.join(' · '), width, ELLIPSIS)), '', currentTheme.fg('textMuted', truncateToWidth('No sessions found.', width, ELLIPSIS)), currentTheme.fg('primary', '─'.repeat(width))); return lines; } @@ -355,7 +353,7 @@ export class SessionPickerComponent extends Container implements Focusable { const time = formatRelativeTime(session.updated_at); const badge = isCurrent ? CURRENT_MARK : ''; const rawTitle = (session.title ?? session.id).trim() || session.id; - const title = formatSessionLabel({ title: rawTitle, metadata: session.metadata }); + const title = rawTitle; const tag = sessionTag(session); const titleSource = tag === undefined ? title : `${title} #${tag}`; diff --git a/apps/pythinker-code/src/tui/presentation/migration-phase-model.ts b/apps/pythinker-code/src/tui/presentation/migration-phase-model.ts deleted file mode 100644 index eca7ce58..00000000 --- a/apps/pythinker-code/src/tui/presentation/migration-phase-model.ts +++ /dev/null @@ -1,144 +0,0 @@ -export type MigrationPhase = 'ask1' | 'ask2' | 'progress' | 'result'; - -export interface MigrationStepStatus { - readonly config: 'pending' | 'done'; - readonly mcp: 'pending' | 'done'; - readonly 'user-history': 'pending' | 'done'; - readonly sessions: 'pending' | 'done'; -} - -export interface MigrationPhaseViewModel { - readonly phase: MigrationPhase; - readonly selectedIndex: number; - readonly progressDone: number; - readonly progressTotal: number; - readonly stepStatus: MigrationStepStatus; - readonly migrationFailed: boolean; - readonly migrationFailureReason: string | undefined; -} - -type MigrationStepKey = keyof MigrationStepStatus; -type MigrationStepState = Record; - -export class MigrationPhaseModel { - private phase: MigrationPhase; - private selectedIndex = 0; - private progressDone = 0; - private progressTotal = 0; - private readonly stepStatus: MigrationStepState = { - config: 'pending', - mcp: 'pending', - 'user-history': 'pending', - sessions: 'pending', - }; - private migrationFailed = false; - private migrationFailureReason: string | undefined; - - constructor(initialPhase?: MigrationPhase) { - this.phase = initialPhase ?? 'ask1'; - } - - moveUp(): void { - if (this.phase === 'ask1' || this.phase === 'ask2') { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - } - } - - moveDown(optionCount: number): void { - if (this.phase === 'ask1' || this.phase === 'ask2') { - this.selectedIndex = Math.min(optionCount - 1, this.selectedIndex + 1); - } - } - - advanceToAsk2(): void { - this.phase = 'ask2'; - this.selectedIndex = 0; - } - - enterProgress(): void { - this.phase = 'progress'; - } - - reportStep(msg: string): void { - const key = msg.replace(/ done$/, ''); - switch (key) { - case 'config': - case 'mcp': - case 'user-history': - case 'sessions': - this.stepStatus[key] = 'done'; - break; - } - } - - reportSessionProgress(done: number, total: number): void { - this.progressDone = done; - this.progressTotal = total; - } - - showResult(): void { - this.phase = 'result'; - } - - showFailure(reason: string | undefined): void { - this.migrationFailed = true; - this.migrationFailureReason = reason; - this.phase = 'result'; - } - - toViewModel(): MigrationPhaseViewModel { - return { - phase: this.phase, - selectedIndex: this.selectedIndex, - progressDone: this.progressDone, - progressTotal: this.progressTotal, - stepStatus: { - config: this.stepStatus.config, - mcp: this.stepStatus.mcp, - 'user-history': this.stepStatus['user-history'], - sessions: this.stepStatus.sessions, - }, - migrationFailed: this.migrationFailed, - migrationFailureReason: this.migrationFailureReason, - }; - } -} - -export function formatMigrationFailureReason(error: unknown): string | undefined { - let reason: string | undefined; - if (error instanceof Error) { - reason = error.message !== '' ? error.message : error.name; - } else if (typeof error === 'string') { - reason = error; - } else if (typeof error === 'object' && error !== null) { - const maybeMessage = (error as { readonly message?: unknown }).message; - if (typeof maybeMessage === 'string' && maybeMessage !== '') { - reason = maybeMessage; - } - } - if (reason === undefined) { - switch (typeof error) { - case 'number': - case 'boolean': - case 'bigint': - reason = `${error}`; - break; - case 'symbol': - reason = - error.description !== undefined ? `Symbol(${error.description})` : 'Symbol rejection'; - break; - case 'function': - reason = error.name !== '' ? `Function ${error.name}` : 'Function rejection'; - break; - case 'object': - if (error !== null) reason = 'Object rejection'; - break; - case 'undefined': - break; - case 'string': - break; - } - } - const trimmed = reason?.trim(); - return trimmed === undefined || trimmed === '' ? undefined : trimmed; -} diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index fbef6b42..77339aa8 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -1,6 +1,3 @@ -import { writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - import { deleteAllKittyImages, type Component, @@ -18,7 +15,6 @@ import type { PromptPart, Session, } from '@pymodel/pythinker-code-sdk'; -import type { MigrationPlan } from '@pymodel/migration-legacy'; import { resolve } from 'pathe'; import type { CLIOptions } from '#/cli/options'; @@ -26,7 +22,6 @@ import { readUpdateCache } from '#/cli/update/cache'; import { readUpdateInstallState } from '#/cli/update/install-state'; import { detectInstallSource } from '#/cli/update/source'; import type { InstallSource } from '#/cli/update/types'; -import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { appendInputHistory, @@ -193,9 +188,6 @@ export interface PythinkerTUIStartupInput { readonly version: string; readonly workDir: string; readonly startupNotice?: string; - readonly migrationPlan?: MigrationPlan | null; - /** When true, run only the migration screen, then exit (the `pythinker migrate` command). */ - readonly migrateOnly?: boolean; } type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; @@ -278,8 +270,6 @@ export class PythinkerTUI { private uninstallRainbowColors: () => void; private signalCleanupHandlers: Array<() => void> = []; private isShuttingDown = false; - private readonly migrationPlan: MigrationPlan | null; - private readonly migrateOnly: boolean; private startupNotice: string | undefined; private keyboardShortcuts: readonly KeyboardShortcut[] = []; private keybindings = defaultKeybindings(); @@ -353,8 +343,6 @@ export class PythinkerTUI { copyFullResponse: startupInput.tuiConfig.copyFullResponse, }; this.options = tuiOptions; - this.migrationPlan = startupInput.migrationPlan ?? null; - this.migrateOnly = startupInput.migrateOnly ?? false; this.startupNotice = startupInput.startupNotice; this.state = createTUIState(tuiOptions); const keybindingWarnings = this.reloadKeybindings(); @@ -495,37 +483,10 @@ export class PythinkerTUI { this.registerSignalHandlers(); // Outer try rolls back signal listeners on startup failure. try { - if (this.migrationPlan !== null) { - // Migration needs the event loop running first (pi-tui component). - this.startEventLoop(); - try { - const migrationResult = await this.runMigrationScreen(this.migrationPlan); - if (this.migrateOnly) { - const failed = migrationResult.decision === 'now' && migrationResult.migrated === false; - this.mouseController.stop(); - this.disposeTerminalTracking(); - this.presentation.stop(); - await this.onExit?.(failed ? 1 : 0); - return; - } - const shouldReplayHistory = await this.initMainTui(); - this.startBackgroundFdAutocomplete(); - await this.finishStartup(shouldReplayHistory); - this.startKeybindingsWatcher(); - } catch (error) { - this.mouseController.stop(); - this.disposeTerminalTracking(); - this.presentation.stop(); - throw error; - } - return; - } - // Start the loop before mounting anything: pi-tui paints on invalidate // even before ui.start(), so mounting first anchors early frames to the // shell cursor and startEventLoop's scroll-to-home would push the live - // frame's top rows into scrollback for good. Same order as the - // migration branch above. + // frame's top rows into scrollback for good. this.startEventLoop(); try { const shouldReplayHistory = await this.initMainTui(); @@ -2335,35 +2296,6 @@ export class PythinkerTUI { ); } - private async runMigrationScreen(plan: MigrationPlan): Promise { - const result = await new Promise((resolve) => { - const screen = new MigrationScreenComponent({ - plan, - sourceHome: plan.sourceHome, - targetHome: this.harness.homeDir, - skipDecisionStep: this.migrateOnly, - requestRender: () => { - this.state.ui.requestRender(); - }, - onComplete: (r) => { - resolve(r); - }, - }); - this.mountEditorReplacement(screen); - }); - this.restoreEditor(); - if (result.decision === 'never') { - // Persist the skip marker `detectPendingMigration` checks, so "Never ask - // again" actually stops the prompt from reappearing every launch. - try { - writeFileSync(join(this.harness.homeDir, '.skip-migration-from-pythinker-cli'), '', 'utf-8'); - } catch { - // Non-blocking: a failed marker write must never crash startup. - } - } - return result; - } - showHelpPanel(): void { this.state.activeDialog = 'help'; this.mountEditorReplacement( diff --git a/apps/pythinker-code/test/cli/options.test.ts b/apps/pythinker-code/test/cli/options.test.ts index b1ed4756..5f7575a1 100644 --- a/apps/pythinker-code/test/cli/options.test.ts +++ b/apps/pythinker-code/test/cli/options.test.ts @@ -12,7 +12,6 @@ function parse(argv: string[]): CLIOptions { (opts) => { captured = opts; }, - () => {}, ); program.exitOverride(); @@ -67,7 +66,6 @@ describe('CLI options parsing', () => { const program = createProgram( '1.2.3', () => {}, - () => {}, ); program.exitOverride(); program.configureOutput({ @@ -85,7 +83,6 @@ describe('CLI options parsing', () => { const program = createProgram( '4.5.6', () => {}, - () => {}, ); program.exitOverride(); program.configureOutput({ @@ -107,7 +104,6 @@ describe('CLI options parsing', () => { () => { throw new Error('main action should not run'); }, - () => {}, (entry, args) => { pluginRunnerCalls.push({ entry, args }); }, @@ -423,7 +419,6 @@ describe('CLI options parsing', () => { throw new Error('main action should not run'); }, () => {}, - () => {}, () => { upgradeCalls += 1; }, @@ -443,7 +438,6 @@ describe('CLI options parsing', () => { const program = createProgram( '0.0.0', () => {}, - () => {}, ); const commandNames: string[] = program.commands .filter((command) => !command.name().startsWith('__')) @@ -458,7 +452,6 @@ describe('CLI options parsing', () => { 'login', 'doctor', 'dashboard', - 'migrate', 'upgrade', ]); }); diff --git a/apps/pythinker-code/test/cli/run-shell.test.ts b/apps/pythinker-code/test/cli/run-shell.test.ts index 4831b0f7..aa3115c4 100644 --- a/apps/pythinker-code/test/cli/run-shell.test.ts +++ b/apps/pythinker-code/test/cli/run-shell.test.ts @@ -39,7 +39,6 @@ const mocks = vi.hoisted(() => { harnessGetCachedAccessToken: vi.fn(), harnessCreateSession: vi.fn(), harnessClose: vi.fn(), - detectPendingMigration: vi.fn<() => Promise>(async () => null), harnessTrack: vi.fn(), pythinkerTuiConstructor: vi.fn(), tuiStart: vi.fn(), @@ -153,10 +152,6 @@ vi.mock('../../src/tui/theme/detect', () => ({ detectTerminalTheme: mocks.detectTerminalTheme, })); -vi.mock('../../src/migration/index', () => ({ - detectPendingMigration: mocks.detectPendingMigration, -})); - vi.mock('node:child_process', () => ({ execSync: mocks.execSync, })); @@ -500,6 +495,34 @@ describe('runShell', () => { }); }); + it('surfaces an invalid target config as an error, not silently', async () => { + mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); + mocks.harnessGetConfig.mockRejectedValue( + new Error('Invalid configuration in ~/.pythinker-code/config.toml'), + ); + + // A broken config.toml must fail loudly before the TUI starts — otherwise the + // user never learns their config is broken. + await expect( + runShell( + { + session: undefined, + continue: false, + rewindFiles: undefined, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ), + ).rejects.toThrow('Invalid configuration'); + expect(mocks.tuiStart).not.toHaveBeenCalled(); + }); + it('closes the harness when TUI startup fails', async () => { mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); mocks.tuiStart.mockRejectedValue(new Error('boom')); @@ -623,33 +646,4 @@ describe('runShell', () => { } }); - it('surfaces an invalid target config as an error for pythinker migrate, not silently', async () => { - mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); - mocks.detectPendingMigration.mockResolvedValue({ totalSessions: 1 }); - mocks.harnessGetConfig.mockRejectedValue( - new Error('Invalid configuration in ~/.pythinker-code/config.toml'), - ); - - // A broken config.toml must fail loudly — `pythinker migrate` must not swallow - // it and proceed, or the user never learns their config is broken. - await expect( - runShell( - { - session: undefined, - continue: false, - rewindFiles: undefined, - yolo: false, - auto: false, - plan: false, - model: undefined, - outputFormat: undefined, - prompt: undefined, - skillsDirs: [], - }, - '1.2.3-test', - { migrateOnly: true }, - ), - ).rejects.toThrow('Invalid configuration'); - expect(mocks.tuiStart).not.toHaveBeenCalled(); - }); }); diff --git a/apps/pythinker-code/test/cli/session-flag-picker.test.ts b/apps/pythinker-code/test/cli/session-flag-picker.test.ts index 8652db93..b4a336e8 100644 --- a/apps/pythinker-code/test/cli/session-flag-picker.test.ts +++ b/apps/pythinker-code/test/cli/session-flag-picker.test.ts @@ -11,7 +11,6 @@ function parse(argv: string[]): CLIOptions { (opts) => { captured = opts; }, - () => {}, ); program.exitOverride(); program.configureOutput({ diff --git a/apps/pythinker-code/test/migration/badge.test.ts b/apps/pythinker-code/test/migration/badge.test.ts deleted file mode 100644 index 39c38ea4..00000000 --- a/apps/pythinker-code/test/migration/badge.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { formatSessionLabel } from '#/migration/badge'; - -describe('formatSessionLabel', () => { - it('prepends [imported] when metadata.imported_from_pythinker_cli === true', () => { - const label = formatSessionLabel({ - title: 'Refactor sessions list', - metadata: { imported_from_pythinker_cli: true }, - }); - expect(label).toBe('[imported] Refactor sessions list'); - }); - - it('does not prepend [imported] when metadata is missing', () => { - const label = formatSessionLabel({ title: 'Plain session' }); - expect(label).toBe('Plain session'); - }); - - it('does not prepend [imported] when metadata is empty', () => { - const label = formatSessionLabel({ title: 'Plain session', metadata: {} }); - expect(label).toBe('Plain session'); - }); - - it('only triggers on the literal boolean true (not truthy values)', () => { - const label = formatSessionLabel({ - title: 'truthy but not true', - metadata: { imported_from_pythinker_cli: 'yes' as unknown }, - }); - expect(label).toBe('truthy but not true'); - }); - - it('does not prepend [imported] when flag is false', () => { - const label = formatSessionLabel({ - title: 'native session', - metadata: { imported_from_pythinker_cli: false }, - }); - expect(label).toBe('native session'); - }); - - it('preserves the title even when it is empty', () => { - const label = formatSessionLabel({ - title: '', - metadata: { imported_from_pythinker_cli: true }, - }); - expect(label).toBe('[imported] '); - }); -}); diff --git a/apps/pythinker-code/test/migration/command.test.ts b/apps/pythinker-code/test/migration/command.test.ts deleted file mode 100644 index eb726078..00000000 --- a/apps/pythinker-code/test/migration/command.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * `pythinker migrate` — a bare, flagless subcommand that delegates to a host - * handler. The migration UI is the native pi-tui screen, covered separately - * by `migration-screen.test.ts`. - */ - -import { Command } from 'commander'; -import { describe, expect, it, vi } from 'vitest'; - -import { registerMigrateCommand } from '#/migration/command'; - -describe('registerMigrateCommand', () => { - it('adds a flagless migrate subcommand to the program', () => { - const program = new Command('pythinker'); - registerMigrateCommand(program, () => {}); - const sub = program.commands.find((c) => c.name() === 'migrate'); - expect(sub).toBeDefined(); - expect(sub!.description()).toContain('Migrate'); - expect(sub!.options).toHaveLength(0); - }); - - it('invokes the host handler when `migrate` runs', () => { - const program = new Command('pythinker'); - const onMigrate = vi.fn(); - registerMigrateCommand(program, onMigrate); - program.parse(['migrate'], { from: 'user' }); - expect(onMigrate).toHaveBeenCalledTimes(1); - }); -}); diff --git a/apps/pythinker-code/test/migration/detect-pending.test.ts b/apps/pythinker-code/test/migration/detect-pending.test.ts deleted file mode 100644 index 41473911..00000000 --- a/apps/pythinker-code/test/migration/detect-pending.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { detectPendingMigration } from '#/migration/detect-pending'; - -let src: string; -let tgt: string; -beforeEach(async () => { - src = await mkdtemp(join(tmpdir(), 'detect-pending-src-')); - tgt = await mkdtemp(join(tmpdir(), 'detect-pending-tgt-')); -}); -afterEach(async () => { - await rm(src, { recursive: true, force: true }); - await rm(tgt, { recursive: true, force: true }); -}); - -describe('detectPendingMigration', () => { - it('returns null when source dir does not exist', async () => { - const plan = await detectPendingMigration({ sourceHome: join(src, 'nope'), targetHome: tgt }); - expect(plan).toBeNull(); - }); - - it('returns null when the migrated marker exists', async () => { - await writeFile(join(src, '.migrated-to-pythinker-code'), '{}', 'utf-8'); - const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); - expect(plan).toBeNull(); - }); - - it('returns null when the skip marker exists in target', async () => { - await writeFile(join(src, 'config.toml'), '', 'utf-8'); - await writeFile(join(tgt, '.skip-migration-from-pythinker-cli'), '', 'utf-8'); - const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); - expect(plan).toBeNull(); - }); - - it('returns null when source has nothing worth migrating', async () => { - // empty source dir, no config/mcp/credentials/sessions - const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); - expect(plan).toBeNull(); - }); - - it('returns null when the only source data is OAuth credentials', async () => { - // OAuth credentials are deliberately never migrated. An install whose - // only legacy data is `credentials/*.json` therefore has nothing to - // offer the migration screen — pythinker-code's own /login flow handles - // re-auth on first use. - await mkdir(join(src, 'credentials'), { recursive: true }); - await writeFile( - join(src, 'credentials', 'pythinker-code.json'), - JSON.stringify({ - access_token: 'a', - refresh_token: 'r', - expires_at: 1, - scope: 's', - token_type: 'Bearer', - }), - 'utf-8', - ); - const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); - expect(plan).toBeNull(); - }); - - it('returns a MigrationPlan when source has migratable data', async () => { - await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); - const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); - expect(plan).not.toBeNull(); - expect(plan?.hasConfig).toBe(true); - }); - - it('returns a MigrationPlan when source has only user-history', async () => { - await mkdir(join(src, 'user-history'), { recursive: true }); - await writeFile(join(src, 'user-history', 'shell.txt'), 'ls\n', 'utf-8'); - const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); - expect(plan).not.toBeNull(); - expect(plan?.hasUserHistory).toBe(true); - }); - - it('does not suppress when the marker targeted a different home', async () => { - await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); - await writeFile( - join(src, '.migrated-to-pythinker-code'), - JSON.stringify({ version: 1, target_path: '/some/other/home' }), - 'utf-8', - ); - const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); - expect(plan).not.toBeNull(); // this target was never migrated → still offer - }); - - it('suppresses when the marker targeted this home', async () => { - await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); - await writeFile( - join(src, '.migrated-to-pythinker-code'), - JSON.stringify({ version: 1, target_path: tgt }), - 'utf-8', - ); - const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); - expect(plan).toBeNull(); - }); -}); diff --git a/apps/pythinker-code/test/migration/migration-screen.test.ts b/apps/pythinker-code/test/migration/migration-screen.test.ts deleted file mode 100644 index 5e4e9bff..00000000 --- a/apps/pythinker-code/test/migration/migration-screen.test.ts +++ /dev/null @@ -1,576 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - MigrationScreenComponent, - type MigrationScreenResult, -} from '#/migration/migration-screen'; -import { darkColors } from '#/tui/theme/colors'; -import type { - MigrationPlan, - MigrationReport, - RunMigrationInput, -} from '@pymodel/migration-legacy'; - -function makePlan(over: Partial = {}): MigrationPlan { - return { - sourceHome: '/x/.pythinker', - hasConfig: true, - hasMcp: true, - hasUserHistory: true, - oauthCredentials: ['pythinker-code.json'], - workdirs: [], - detectedPlugins: [], - detectedMcpOauthServers: [], - totalSessions: 1365, - ...over, - }; -} - -function render(c: MigrationScreenComponent): string { - return c.render(80).join('\n'); -} - -describe('MigrationScreenComponent — ask phase', () => { - it('ask1 renders the intro block and three options', () => { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - const out = render(c); - expect(out).toContain('Migrate from pythinker-cli'); - expect(out).toContain('1365 sessions'); - expect(out).toContain('Migrate now'); - expect(out).toContain('Ask me later'); - expect(out).toContain('Never ask again'); - }); - - it('ask1 summary does not mention pythinker-cli login (oauth is not a migrated kind)', async () => { - // OAuth credentials are deliberately never migrated, so the pre-migration - // summary must not list "pythinker-cli login" alongside the real migratable - // data classes — that framing makes users believe their session will - // carry over, which it does not. - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - const out = render(c); - expect(out).not.toContain('pythinker-cli login'); - expect(out).not.toContain('/login'); - }); - - it('picking "Ask me later" at ask1 completes with decision=later', () => { - let result: { decision: string } | undefined; - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: (r) => { - result = r; - }, - }); - c.handleInput('\u001B[B'); // Down -> "Ask me later" - c.handleInput('\r'); // Enter - expect(result?.decision).toBe('later'); - }); - - it('"Migrate now" -> "Config only" advances ask1 -> ask2 and resolves scope.sessions=false', async () => { - let captured: RunMigrationInput | undefined; - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - runMigration: async (input) => { - captured = input; - return makeReport(); - }, - onComplete: () => {}, - }); - c.handleInput('\r'); // ask1: "Migrate now" - c.handleInput('\r'); // ask2: "Config only" (first option) - await new Promise((r) => setTimeout(r, 0)); - expect(captured?.scope.sessions).toBe(false); - }); - - it('"Migrate now" -> "Config + sessions" begins migration immediately with sessions=true', async () => { - let captured: RunMigrationInput | undefined; - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - runMigration: async (input) => { - captured = input; - return makeReport(); - }, - onComplete: () => {}, - }); - c.handleInput('\r'); // ask1: Migrate now - c.handleInput('\u001B[B'); // ask2: down -> Also migrate sessions - c.handleInput('\r'); // ask2 select -> "Config + N sessions" begins migration immediately - await new Promise((r) => setTimeout(r, 0)); - expect(captured?.scope.sessions).toBe(true); - }); - - it('ask2 shows the detected session count alongside the "config only" option', () => { - const c = new MigrationScreenComponent({ - plan: makePlan({ totalSessions: 1365 }), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - c.handleInput('\r'); // ask1: Migrate now -> ask2 - const out = render(c); - expect(out).toContain('Config only'); - // Concrete count so the user sees the cost of "+ sessions" up front. - expect(out).toContain('Config + 1365 sessions'); - expect(out).not.toContain('Most recent'); - expect(out).not.toContain('Migrate now'); - }); - - it('ask2 falls back to "Config + all sessions" when no sessions were detected', () => { - const c = new MigrationScreenComponent({ - plan: makePlan({ totalSessions: 0 }), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - c.handleInput('\r'); // ask1 -> ask2 - const out = render(c); - expect(out).toContain('Config + all sessions'); - // "Config + 0 sessions" would read as an obvious dead-end. - expect(out).not.toContain('Config + 0 sessions'); - }); - - it('skipDecisionStep starts at the scope question with the now/later/never gate hidden', () => { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - skipDecisionStep: true, - onComplete: () => {}, - }); - const out = render(c); - expect(out).toContain('Migrate chat sessions too?'); - expect(out).not.toContain('Migrate now'); - expect(out).not.toContain('Never ask again'); - }); - - it('skipDecisionStep -> "Config only" resolves scope without the decision step', async () => { - let captured: RunMigrationInput | undefined; - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - skipDecisionStep: true, - runMigration: async (input) => { - captured = input; - return makeReport(); - }, - onComplete: () => {}, - }); - c.handleInput('\r'); // ask2: "Config only" (first option) — no ask1 gate - await new Promise((r) => setTimeout(r, 0)); - expect(captured?.scope.sessions).toBe(false); - }); -}); - -describe('MigrationScreenComponent — progress phase', () => { - it('renders a step checklist and the session counter when in progress', () => { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - // expose progress rendering via the test hook (see Step 5.2) - c._testEnterProgress(); - c._testUpdateStep('config done'); - c._testUpdateSessionProgress(32, 50); - const out = c.render(80).join('\n'); - expect(out).toContain('Migrating from pythinker-cli'); - expect(out).toContain('32 / 50'); - expect(out).toContain('Config'); - }); - - it('animates the progress spinner while a migration step runs', async () => { - vi.useFakeTimers(); - try { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - skipDecisionStep: true, - // A migration that never settles keeps the screen in the progress - // phase so the spinner animation can be observed. - runMigration: () => new Promise(() => {}), - onComplete: () => {}, - }); - c.handleInput('\r'); // ask2: "Config only" -> migration begins - c._testUpdateSessionProgress(1, 3); // surface the spinner line - const before = c.render(80).join('\n'); - vi.advanceTimersByTime(400); // several spinner frames - const after = c.render(80).join('\n'); - // Before the fix nothing advanced the spinner — the frame, and the whole - // progress render, stayed frozen on the first braille glyph. - expect(after).not.toBe(before); - } finally { - vi.useRealTimers(); - } - }); - - it('tracks Config and MCP as independent steps', () => { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - c._testEnterProgress(); - c._testUpdateStep('config done'); // config finished; MCP has not started - const out = c.render(80).join('\n'); - // Four checklist rows (config, mcp, user-history, sessions). With only - // config done, exactly one shows ✓ and the other three show ◐ — MCP is - // its own step and stays pending. - expect((out.match(/✓/g) ?? []).length).toBe(1); - expect((out.match(/◐/g) ?? []).length).toBe(3); - }); -}); - -function makeReport( - over: Partial = {}, - summaryOver: Partial = {}, - noticesOver: Partial = {}, -): MigrationReport { - return { - startedAt: 's', - completedAt: 'e', - migratorVersion: '0.1.1', - source: '/x/.pythinker', - target: '/y/.pythinker-code', - summary: { - config: { - migrated: true, - tuiExtracted: false, - droppedProviders: [], - droppedModels: [], - droppedKeys: [], - configConflicts: [], - wroteSiblingDueToConflict: false, - wroteTuiSibling: false, - migratedHooks: 0, - droppedHooks: 0, - siblingContents: { providers: [], models: [], hooks: 0 }, - }, - mcp: { mergedServers: [], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: false }, - userHistory: { copied: 12, skippedExisting: 0 }, - skills: { copied: 0, skippedExisting: 0 }, - sessions: { - scope: 'all', - bucketsScanned: 0, - bucketsSkippedNonlocalKaos: 0, - bucketsSkippedNoWorkdirFound: 0, - sessionsAttempted: 50, - sessionsMigrated: 50, - sessionsAlreadyMigrated: 0, - sessionsSkippedPlaceholder: 0, - sessionsSkippedEmpty: 0, - sessionsSkippedMalformed: 0, - sessionsFailed: [], - sessionsConflicts: [], - ...over, - }, - ...summaryOver, - }, - notices: { - mcpOauthServersRequiringReauth: [], - oauthLoginsRequiringRelogin: [], - detectedPlugins: ['p1', 'p2'], - configConflictNotice: null, - tuiConflictNotice: null, - ...noticesOver, - }, - }; -} - -describe('MigrationScreenComponent — result phase', () => { - it('renders the report summary including plugin notices', () => { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - c._testShowResult(makeReport()); - const out = c.render(80).join('\n'); - expect(out).toContain('Migration complete'); - expect(out).toContain('50 sessions migrated'); - expect(out).toContain('2 pythinker-cli plugins'); - }); - - it('renders migrated hooks in the ✓ line and dropped hooks as a warning', () => { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - c._testShowResult( - makeReport( - {}, - { - config: { - migrated: true, - tuiExtracted: false, - droppedProviders: [], - droppedModels: [], - droppedKeys: [], - configConflicts: [], - wroteSiblingDueToConflict: false, - wroteTuiSibling: false, - migratedHooks: 2, - droppedHooks: 1, - siblingContents: { providers: [], models: [], hooks: 0 }, - }, - }, - ), - ); - const out = c.render(80).join('\n'); - expect(out).toContain('· hooks'); // appears in the ✓ migrated-kinds line - expect(out).toContain('1 hooks dropped'); - }); - - it('Enter on the result screen completes with the prior decision', () => { - let result: MigrationScreenResult | undefined; - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: (r) => { - result = r; - }, - }); - c._testShowResult(makeReport()); - c.handleInput('\r'); - expect(result?.decision).toBe('now'); - expect(result?.migrated).toBe(true); - }); - - it('omits a data class from the result when it was not migrated', () => { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - // config skipped (e.g. a malformed legacy config.toml). - c._testShowResult( - makeReport( - {}, - { - config: { - migrated: false, - tuiExtracted: false, - droppedProviders: [], - droppedModels: [], - droppedKeys: [], - configConflicts: [], - wroteSiblingDueToConflict: false, - wroteTuiSibling: false, - migratedHooks: 0, - droppedHooks: 0, - siblingContents: { providers: [], models: [], hooks: 0 }, - }, - }, - ), - ); - const out = c.render(80).join('\n'); - // REPL history (copied) is still shown... - expect(out).toContain('REPL history'); - // ...but config must not be claimed as migrated. - expect(out).not.toContain('config'); - }); - - it('surfaces conflict and failure warnings on the result screen', () => { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - c._testShowResult( - makeReport( - { sessionsFailed: [{ sourcePath: '/s', reason: 'bad' }] }, - { - config: { - migrated: true, - tuiExtracted: false, - droppedProviders: [], - droppedModels: [], - droppedKeys: [], - configConflicts: [], - wroteSiblingDueToConflict: true, - wroteTuiSibling: false, - migratedHooks: 0, - droppedHooks: 0, - siblingContents: { providers: [], models: [], hooks: 0 }, - }, - mcp: { mergedServers: ['m'], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: true }, - }, - ), - ); - const out = c.render(120).join('\n'); - expect(out).toContain('config.migrated-from-pythinker-cli.toml'); - expect(out).toContain('mcp.migrated-from-pythinker-cli.json'); - expect(out).toContain('1 sessions failed'); - }); - - it('lists sibling-file contents in the config-fallback warning so the user knows what to merge', () => { - // When the target's `config.toml` could not be parsed and migration writes - // to `config.migrated-from-pythinker-cli.toml` instead, the result screen must - // (a) name the sibling, (b) say what's in it so the user knows what to - // merge by hand, and (c) describe the trigger accurately (parse failure, - // not "unreadable"). Otherwise users have to crack the file open to find - // out — and they may not realize hooks landed in there at all. - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - c._testShowResult( - makeReport( - {}, - { - config: { - migrated: true, - tuiExtracted: false, - droppedProviders: [], - droppedModels: [], - droppedKeys: [], - configConflicts: [], - wroteSiblingDueToConflict: true, - wroteTuiSibling: false, - migratedHooks: 0, - droppedHooks: 0, - siblingContents: { - providers: ['openai', 'managed:kimi-code'], - models: ['gpt4'], - hooks: 3, - }, - }, - }, - ), - ); - const out = c.render(120).join('\n'); - expect(out).toContain('config.migrated-from-pythinker-cli.toml'); - // Accurate trigger description (file parses, not "unreadable"). - expect(out).toContain('could not be parsed'); - // Enumeration of what's inside the sibling. - expect(out).toContain('2 providers'); - expect(out).toContain('1 model'); - expect(out).toContain('3 hooks'); - }); - - it('shows skipped empty sessions as a muted line, not a failure', () => { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - c._testShowResult(makeReport({ sessionsSkippedEmpty: 3 })); - const out = c.render(80).join('\n'); - expect(out).toContain('3 empty sessions skipped'); - // It is informational, not a failure. - expect(out).not.toContain('3 sessions failed'); - }); - - it('lists kept config settings on the result screen when pythinker-cli differed', () => { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - c._testShowResult( - makeReport( - {}, - { - config: { - migrated: true, - tuiExtracted: false, - droppedProviders: [], - droppedModels: [], - droppedKeys: [], - configConflicts: ['default_model', 'providers.pythinker'], - wroteSiblingDueToConflict: false, - wroteTuiSibling: false, - migratedHooks: 0, - droppedHooks: 0, - siblingContents: { providers: [], models: [], hooks: 0 }, - }, - }, - ), - ); - const out = c.render(80).join('\n'); - expect(out).toContain('2 config conflicts kept yours'); - expect(out).toContain('default_model · providers.pythinker'); - }); - - it('surfaces MCP servers that need re-authentication', () => { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - }); - c._testShowResult(makeReport({}, {}, { mcpOauthServersRequiringReauth: ['srv-a', 'srv-b'] })); - const out = c.render(80).join('\n'); - expect(out).toContain('2 MCP servers need re-authentication'); - }); -}); - -describe('MigrationScreenComponent — execution wiring', () => { - it('runs migration after the ask phase and lands on the result phase', async () => { - const fakeReport = makeReport(); - let onCompleteResult: MigrationScreenResult | undefined; - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: (r) => { - onCompleteResult = r; - }, - // injected runner for testability — no filesystem access - runMigration: async (_input) => fakeReport, - }); - c.handleInput('\r'); // ask1: Migrate now - c.handleInput('\r'); // ask2: Config only -> begins migration - // migration is async; wait a tick - await new Promise((res) => setTimeout(res, 0)); - expect(c.render(80).join('\n')).toContain('Migration complete'); - c.handleInput('\r'); // dismiss result - expect(onCompleteResult?.decision).toBe('now'); - expect(onCompleteResult?.migrated).toBe(true); - }); - - it('lands on the failure screen with the runner rejection reason', async () => { - const c = new MigrationScreenComponent({ - plan: makePlan(), - sourceHome: '/x/.pythinker', - targetHome: '/y/.pythinker-code', - onComplete: () => {}, - runMigration: async () => { - throw new Error('boom'); - }, - }); - c.handleInput('\r'); // ask1: Migrate now - c.handleInput('\r'); // ask2: Config only -> begins migration - await new Promise((res) => setTimeout(res, 0)); - const out = c.render(80).join('\n'); - expect(out).toContain('Migration failed'); - expect(out).toContain('Reason: boom'); - }); -}); diff --git a/apps/pythinker-code/test/tui/components/dialogs/session-picker.test.ts b/apps/pythinker-code/test/tui/components/dialogs/session-picker.test.ts index 96e6c148..515616bd 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/session-picker.test.ts @@ -271,39 +271,6 @@ describe('SessionPickerComponent', () => { expect(headerLine).not.toMatch(/Short title\s{8,}/); }); - it('prepends [imported] badge before the title for sessions migrated from pythinker-cli', () => { - const now = new Date('2026-05-11T12:00:00.000Z').getTime(); - vi.spyOn(Date, 'now').mockReturnValue(now); - - const component = new SessionPickerComponent({ - sessions: [ - { - id: 'ses_imported', - title: 'Migrated session', - work_dir: '/tmp/project', - updated_at: now - 60 * 1000, - metadata: { imported_from_pythinker_cli: true }, - }, - { - id: 'ses_native', - title: 'Fresh session', - work_dir: '/tmp/project', - updated_at: now - 60 * 1000, - }, - ], - loading: false, - currentSessionId: 'ses_other', - onSelect: vi.fn(), - onCancel: vi.fn(), - }); - - const lines = component.render(120).map((line) => stripAnsi(line)); - const importedLine = lines.find((line) => line.includes('Migrated session')); - const nativeLine = lines.find((line) => line.includes('Fresh session')); - expect(importedLine).toContain('[imported] Migrated session'); - expect(nativeLine).not.toContain('[imported]'); - }); - it('keeps every rendered line within the terminal width even for CJK content', () => { const now = new Date('2026-05-11T12:00:00.000Z').getTime(); vi.spyOn(Date, 'now').mockReturnValue(now); @@ -350,7 +317,6 @@ describe('SessionPickerComponent', () => { last_prompt: 'please redesign the picker UI to be much nicer than before', work_dir: '/Users/getlong/Development/cesiumdb', updated_at: now - 5 * 60 * 1000, - metadata: { imported_from_pythinker_cli: true }, }, ], loading: false, diff --git a/apps/pythinker-code/test/tui/parity/feature-matrix.ts b/apps/pythinker-code/test/tui/parity/feature-matrix.ts index a3bfb14a..f299742c 100644 --- a/apps/pythinker-code/test/tui/parity/feature-matrix.ts +++ b/apps/pythinker-code/test/tui/parity/feature-matrix.ts @@ -232,10 +232,10 @@ const commandCases: readonly ParityCase[] = COMMANDS.map((command) => export const PARITY_CASES: readonly ParityCase[] = [ automatedCase({ - id: 'lifecycle-auth-migration-error-recovery', - area: 'lifecycle, authentication, migration, and startup error recovery', + id: 'lifecycle-auth-error-recovery', + area: 'lifecycle, authentication, and startup error recovery', legacyTest: LEGACY_TEST_PATHS.startup, - scenarioId: 'legacy.lifecycle.startup_auth_migration_recovery', + scenarioId: 'legacy.lifecycle.startup_auth_recovery', requiredEvidence: ['unit', 'headless-renderer', 'pty'], }), automatedCase({ diff --git a/apps/pythinker-code/test/tui/presentation/migration-phase-model.test.ts b/apps/pythinker-code/test/tui/presentation/migration-phase-model.test.ts deleted file mode 100644 index b1a660ea..00000000 --- a/apps/pythinker-code/test/tui/presentation/migration-phase-model.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - formatMigrationFailureReason, - MigrationPhaseModel, -} from '../../../src/tui/presentation/migration-phase-model'; - -describe('MigrationPhaseModel', () => { - it('starts with the required default state and honors an initial phase', () => { - expect(new MigrationPhaseModel().toViewModel()).toEqual({ - phase: 'ask1', - selectedIndex: 0, - progressDone: 0, - progressTotal: 0, - stepStatus: { - config: 'pending', - mcp: 'pending', - 'user-history': 'pending', - sessions: 'pending', - }, - migrationFailed: false, - migrationFailureReason: undefined, - }); - expect(new MigrationPhaseModel('ask2').toViewModel().phase).toBe('ask2'); - }); - - it('moves up within ask phases and clamps at zero', () => { - const model = new MigrationPhaseModel(); - model.moveDown(3); - model.moveDown(3); - model.moveUp(); - expect(model.toViewModel().selectedIndex).toBe(1); - model.moveUp(); - model.moveUp(); - expect(model.toViewModel().selectedIndex).toBe(0); - }); - - it('moves down within ask phases using the exact option-count bound', () => { - const model = new MigrationPhaseModel('ask2'); - model.moveDown(2); - model.moveDown(2); - expect(model.toViewModel().selectedIndex).toBe(1); - }); - - it.each(['progress', 'result'] as const)( - 'makes navigation a full-state no-op during the %s phase', - (phase) => { - const model = new MigrationPhaseModel(phase); - model.reportSessionProgress(2, 5); - model.reportStep('mcp done'); - if (phase === 'result') { - model.showFailure('failed'); - } - const before = model.toViewModel(); - - model.moveUp(); - model.moveDown(4); - - expect(model.toViewModel()).toEqual(before); - }, - ); - - it('advances to ask2 unconditionally and resets only selection', () => { - const model = new MigrationPhaseModel('result'); - model.reportSessionProgress(3, 8); - model.reportStep('sessions done'); - model.showFailure('kept'); - - model.advanceToAsk2(); - - expect(model.toViewModel()).toEqual({ - phase: 'ask2', - selectedIndex: 0, - progressDone: 3, - progressTotal: 8, - stepStatus: { - config: 'pending', - mcp: 'pending', - 'user-history': 'pending', - sessions: 'done', - }, - migrationFailed: true, - migrationFailureReason: 'kept', - }); - }); - - it('enters progress unconditionally without resetting progress or steps', () => { - const model = new MigrationPhaseModel('result'); - model.reportSessionProgress(4, 9); - model.reportStep('user-history done'); - - model.enterProgress(); - - expect(model.toViewModel()).toMatchObject({ - phase: 'progress', - progressDone: 4, - progressTotal: 9, - stepStatus: { - config: 'pending', - mcp: 'pending', - 'user-history': 'done', - sessions: 'pending', - }, - }); - }); - - it('marks only the exactly matched migration step done', () => { - const model = new MigrationPhaseModel(); - - model.reportStep('config done'); - - expect(model.toViewModel().stepStatus).toEqual({ - config: 'done', - mcp: 'pending', - 'user-history': 'pending', - sessions: 'pending', - }); - }); - - it.each(['unknown-step done', 'not-a-step'])( - 'leaves step status unchanged for the unrecognized message %s', - (message) => { - const model = new MigrationPhaseModel(); - const before = model.toViewModel().stepStatus; - - model.reportStep(message); - - expect(model.toViewModel().stepStatus).toEqual(before); - }, - ); - - it('passes session progress through without validation or clamping', () => { - const model = new MigrationPhaseModel(); - - model.reportSessionProgress(-2, -7); - - expect(model.toViewModel()).toMatchObject({ - progressDone: -2, - progressTotal: -7, - }); - }); - - it('shows a result without clearing an existing failure', () => { - const model = new MigrationPhaseModel(); - model.showFailure('migration failed'); - - model.showResult(); - - expect(model.toViewModel()).toMatchObject({ - phase: 'result', - migrationFailed: true, - migrationFailureReason: 'migration failed', - }); - }); - - it('shows failure and passes an undefined reason through verbatim', () => { - const model = new MigrationPhaseModel('progress'); - - model.showFailure(undefined); - - expect(model.toViewModel()).toMatchObject({ - phase: 'result', - migrationFailed: true, - migrationFailureReason: undefined, - }); - }); - - it('returns detached, exact snapshots of current state', () => { - const model = new MigrationPhaseModel('progress'); - model.reportStep('mcp done'); - model.reportSessionProgress(6, 10); - const first = model.toViewModel(); - const second = model.toViewModel(); - - expect(first).toEqual({ - phase: 'progress', - selectedIndex: 0, - progressDone: 6, - progressTotal: 10, - stepStatus: { - config: 'pending', - mcp: 'done', - 'user-history': 'pending', - sessions: 'pending', - }, - migrationFailed: false, - migrationFailureReason: undefined, - }); - expect(first).not.toBe(second); - expect(first.stepStatus).not.toBe(second.stepStatus); - }); -}); - -describe('formatMigrationFailureReason', () => { - it('formats errors with non-empty and empty messages', () => { - expect(formatMigrationFailureReason(new Error('migration failed'))).toBe('migration failed'); - expect(formatMigrationFailureReason(new Error(''))).toBe('Error'); - }); - - it('formats strings and objects with usable messages', () => { - expect(formatMigrationFailureReason('rejected')).toBe('rejected'); - expect(formatMigrationFailureReason({ message: 'object failed' })).toBe('object failed'); - }); - - it('falls back for objects without a usable message', () => { - expect(formatMigrationFailureReason({ code: 1 })).toBe('Object rejection'); - expect(formatMigrationFailureReason({ message: '' })).toBe('Object rejection'); - }); - - it('formats numeric, boolean, and bigint primitive values', () => { - expect(formatMigrationFailureReason(42)).toBe('42'); - expect(formatMigrationFailureReason(false)).toBe('false'); - expect(formatMigrationFailureReason(12n)).toBe('12'); - }); - - it('formats symbols with and without descriptions', () => { - expect(formatMigrationFailureReason(Symbol('migration'))).toBe('Symbol(migration)'); - expect(formatMigrationFailureReason(Symbol())).toBe('Symbol rejection'); - }); - - it('formats named and anonymous functions', () => { - function namedMigration(): void {} - - expect(formatMigrationFailureReason(namedMigration)).toBe('Function namedMigration'); - expect(formatMigrationFailureReason(function () {})).toBe('Function rejection'); - }); - - it('returns undefined for undefined and resolved whitespace', () => { - expect(formatMigrationFailureReason(undefined)).toBeUndefined(); - expect(formatMigrationFailureReason(new Error(' '))).toBeUndefined(); - expect(formatMigrationFailureReason({ message: ' ' })).toBeUndefined(); - expect(formatMigrationFailureReason('')).toBeUndefined(); - }); -}); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index 5ed7adf4..27640c7c 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -12,7 +12,6 @@ import { log, type GoalSnapshot, } from '@pymodel/pythinker-code-sdk'; -import type { MigrationPlan } from '@pymodel/migration-legacy'; import { describe, expect, it, vi } from 'vitest'; import { BannerProvider } from '#/tui/banner/banner-provider'; @@ -87,10 +86,13 @@ interface ThemeTrackingDriver extends StartupDriver { refreshTerminalThemeTracking(): void; } -interface MigrateExitDriver extends StartupDriver { +interface InitMainTuiDriver extends StartupDriver { + initMainTui(): Promise; +} + +interface StartFailureDriver extends StartupDriver { start(): Promise; - onExit?: (code?: number) => Promise; - runMigrationScreen(plan: unknown): Promise; + startEventLoop(): void; initMainTui(): Promise; terminalFocusTrackingDispose?: () => void; } @@ -179,18 +181,6 @@ function footerRowKinds(viewModel: FooterViewModel | undefined): readonly string return viewModel?.rows.map((row) => row.kind) ?? []; } -const MIGRATION_PLAN: MigrationPlan = { - sourceHome: '/x/.pythinker', - hasConfig: false, - hasMcp: false, - hasUserHistory: false, - oauthCredentials: [], - workdirs: [], - detectedPlugins: [], - detectedMcpOauthServers: [], - totalSessions: 0, -}; - function makeStartupInput( cliOptions: Partial = {}, tuiConfig: Partial = {}, @@ -1314,7 +1304,7 @@ describe('PythinkerTUI startup', () => { const stop = vi.spyOn(driver, 'stop').mockResolvedValue(undefined); copyTextToClipboardMock.mockClear(); - await expect((driver as unknown as MigrateExitDriver).initMainTui()).resolves.toBe(false); + await expect((driver as unknown as InitMainTuiDriver).initMainTui()).resolves.toBe(false); await (driver as unknown as { bootstrapFromPicker(): Promise }).bootstrapFromPicker(); const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; @@ -1380,7 +1370,7 @@ describe('PythinkerTUI startup', () => { const driver = makeDriver(harness, makeStartupInput({ session: '' })); const stop = vi.spyOn(driver, 'stop').mockResolvedValue(undefined); - await expect((driver as unknown as MigrateExitDriver).initMainTui()).resolves.toBe(false); + await expect((driver as unknown as InitMainTuiDriver).initMainTui()).resolves.toBe(false); await (driver as unknown as { bootstrapFromPicker(): Promise }).bootstrapFromPicker(); const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; @@ -1748,53 +1738,6 @@ describe('PythinkerTUI startup', () => { expect(driver.state.appState.sessionId).toBe(''); }); - it('disposes terminal focus/theme tracking on the pythinker migrate exit', async () => { - const harness = makeHarness(); - const driver = makeDriver(harness, { - ...makeStartupInput(), - migrationPlan: MIGRATION_PLAN, - migrateOnly: true, - }) as unknown as MigrateExitDriver; - // pi-tui start/stop and focus tracking touch the real TTY — stub the I/O. - vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); - vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); - // The migration screen would await user input; resolve it immediately. - vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); - const onExit = vi.fn(async () => {}); - driver.onExit = onExit; - - await driver.start(); - - // `pythinker migrate` exits via process.exit; startEventLoop() installed focus - // tracking, so the exit path must dispose it — otherwise the terminal - // keeps emitting focus/OSC sequences after the command finishes. - expect(driver.terminalFocusTrackingDispose).toBeUndefined(); - expect(onExit).toHaveBeenCalledWith(0); - }); - - it('disposes terminal tracking when post-migration startup fails', async () => { - const harness = makeHarness(); - const driver = makeDriver(harness, { - ...makeStartupInput(), - migrationPlan: MIGRATION_PLAN, - migrateOnly: false, - }) as unknown as MigrateExitDriver; - vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); - vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); - // The migration screen resolves "later"; startup then continues into - // initMainTui(), which fails (e.g. a session-resume error). - vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); - vi.spyOn(driver, 'initMainTui').mockRejectedValue(new Error('resume boom')); - - await expect(driver.start()).rejects.toThrow('resume boom'); - - // The focus tracking installed by startEventLoop() must be torn down - // before the error propagates — not left active after the process exits. - expect(driver.terminalFocusTrackingDispose).toBeUndefined(); - }); - it('keeps non-login startup session errors fatal', async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { @@ -1817,7 +1760,7 @@ describe('PythinkerTUI startup', () => { const driver = makeDriver( harness, makeStartupInput({ session: 'missing-session' }), - ) as unknown as MigrateExitDriver; + ) as unknown as InitMainTuiDriver; await expect(driver.initMainTui()).rejects.toThrow('Session "missing-session" not found.'); expect(uiContainsFooter(driver)).toBe(false); @@ -1831,7 +1774,7 @@ describe('PythinkerTUI startup', () => { const driver = makeDriver( harness, makeStartupInput({ session: 'ses-target' }), - ) as unknown as MigrateExitDriver; + ) as unknown as InitMainTuiDriver; // Not mounted until init() succeeds. expect(uiContainsFooter(driver)).toBe(false); @@ -1857,7 +1800,7 @@ describe('PythinkerTUI startup', () => { const driver = makeDriver( harness, makeStartupInput({ session: 'ses-target' }), - ) as unknown as MigrateExitDriver; + ) as unknown as InitMainTuiDriver; await driver.initMainTui(); @@ -1902,7 +1845,7 @@ describe('PythinkerTUI startup', () => { const driver = makeDriver( harness, makeStartupInput({ session: 'ses-target' }), - ) as unknown as MigrateExitDriver; + ) as unknown as InitMainTuiDriver; await driver.initMainTui(); @@ -1949,7 +1892,7 @@ describe('PythinkerTUI startup', () => { const driver = makeDriver( harness, makeStartupInput({ session: 'ses-target' }), - ) as unknown as MigrateExitDriver; + ) as unknown as InitMainTuiDriver; await driver.initMainTui(); @@ -2095,4 +2038,36 @@ describe('footer update status poll', () => { rmSync(home, { recursive: true, force: true }); } }, 30_000); + + it('disposes terminal tracking when startup fails', async () => { + const harness = makeHarness(); + const driver = makeDriver(harness, makeStartupInput()) as unknown as StartFailureDriver; + // pi-tui start/stop and focus tracking touch the real TTY — stub the I/O. + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); + vi.spyOn(driver, 'initMainTui').mockRejectedValue(new Error('resume boom')); + + // startEventLoop() only installs focus tracking once the terminal reports a + // width, so give it one — otherwise there is nothing to dispose and the + // assertion below would hold no matter what the teardown does. + const stdoutColumns = Object.getOwnPropertyDescriptor(process.stdout, 'columns'); + Object.defineProperty(process.stdout, 'columns', { configurable: true, value: 80 }); + try { + driver.startEventLoop(); + expect(driver.terminalFocusTrackingDispose).toBeDefined(); + + await expect(driver.start()).rejects.toThrow('resume boom'); + + // The focus tracking installed by startEventLoop() must be torn down before + // the error propagates — not left active after the process exits. + expect(driver.terminalFocusTrackingDispose).toBeUndefined(); + } finally { + if (stdoutColumns === undefined) { + Reflect.deleteProperty(process.stdout, 'columns'); + } else { + Object.defineProperty(process.stdout, 'columns', stdoutColumns); + } + } + }); }); diff --git a/apps/pythinker-web/index.html b/apps/pythinker-web/index.html index dd2098a3..b681b5bd 100644 --- a/apps/pythinker-web/index.html +++ b/apps/pythinker-web/index.html @@ -21,6 +21,8 @@ if (v === 'light' || v === 'dark' || v === 'system') { document.documentElement.dataset.colorScheme = v; } + var dark = v === 'dark' || (v !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.toggleAttribute('data-ds-dark-theme', dark); } catch (e) { /* ignore */ } diff --git a/apps/pythinker-web/public/brand/mascot-failed.png b/apps/pythinker-web/public/brand/mascot-failed.png new file mode 100644 index 00000000..c73d9664 Binary files /dev/null and b/apps/pythinker-web/public/brand/mascot-failed.png differ diff --git a/apps/pythinker-web/public/brand/mascot-idle-strip.png b/apps/pythinker-web/public/brand/mascot-idle-strip.png deleted file mode 100644 index d4f6ab79..00000000 Binary files a/apps/pythinker-web/public/brand/mascot-idle-strip.png and /dev/null differ diff --git a/apps/pythinker-web/public/brand/mascot-idle.png b/apps/pythinker-web/public/brand/mascot-idle.png new file mode 100644 index 00000000..f66c2b2a Binary files /dev/null and b/apps/pythinker-web/public/brand/mascot-idle.png differ diff --git a/apps/pythinker-web/public/brand/mascot-jumping.png b/apps/pythinker-web/public/brand/mascot-jumping.png new file mode 100644 index 00000000..f1a3664f Binary files /dev/null and b/apps/pythinker-web/public/brand/mascot-jumping.png differ diff --git a/apps/pythinker-web/public/brand/mascot-laptop.png b/apps/pythinker-web/public/brand/mascot-laptop.png new file mode 100644 index 00000000..2ea550bf Binary files /dev/null and b/apps/pythinker-web/public/brand/mascot-laptop.png differ diff --git a/apps/pythinker-web/public/brand/mascot-review.png b/apps/pythinker-web/public/brand/mascot-review.png new file mode 100644 index 00000000..2237027b Binary files /dev/null and b/apps/pythinker-web/public/brand/mascot-review.png differ diff --git a/apps/pythinker-web/public/brand/mascot-running-left.png b/apps/pythinker-web/public/brand/mascot-running-left.png new file mode 100644 index 00000000..02c386df Binary files /dev/null and b/apps/pythinker-web/public/brand/mascot-running-left.png differ diff --git a/apps/pythinker-web/public/brand/mascot-running-right.png b/apps/pythinker-web/public/brand/mascot-running-right.png new file mode 100644 index 00000000..8bab3df7 Binary files /dev/null and b/apps/pythinker-web/public/brand/mascot-running-right.png differ diff --git a/apps/pythinker-web/public/brand/mascot-states.png b/apps/pythinker-web/public/brand/mascot-states.png deleted file mode 100644 index 6692567e..00000000 Binary files a/apps/pythinker-web/public/brand/mascot-states.png and /dev/null differ diff --git a/apps/pythinker-web/public/brand/mascot-waving.png b/apps/pythinker-web/public/brand/mascot-waving.png new file mode 100644 index 00000000..8706dd7c Binary files /dev/null and b/apps/pythinker-web/public/brand/mascot-waving.png differ diff --git a/apps/pythinker-web/src/App.vue b/apps/pythinker-web/src/App.vue index 584a0d76..742a1344 100644 --- a/apps/pythinker-web/src/App.vue +++ b/apps/pythinker-web/src/App.vue @@ -29,6 +29,7 @@ import DebugPanel from './debug/DebugPanel.vue'; import { isTraceEnabled } from './debug/trace'; import { usePythinkerWebClient } from './composables/usePythinkerWebClient'; import { useIsMobile } from './composables/useIsMobile'; +import { useIsDark } from './composables/useIsDark'; import type { AppConfig, ThinkingLevel } from './api/types'; import type { FilePreviewRequest, ToolMedia } from './types'; @@ -42,6 +43,11 @@ const debugEnabled = isTraceEnabled(); // Narrow viewports (≤640px) render the single-column mobile shell; desktop is // unchanged. jsdom defaults to false (desktop) so component tests are unaffected. const isMobile = useIsMobile(); +const isDark = useIsDark(); +watch(isDark, (dark) => { + document.documentElement.toggleAttribute('data-ds-dark-theme', dark); + void window.pythinkerDesktop?.setThemeSource(dark ? 'dark' : 'light'); +}, { immediate: true }); // Mobile sheet visibility const showMobileSwitcher = ref(false); @@ -1444,13 +1450,31 @@ function openPr(url: string): void { } :global(html[data-desktop-platform='darwin'] .side), :global(html[data-desktop-platform='darwin'] .sidebar-rail) { - background: color-mix(in srgb, var(--panel) 55%, transparent); + background: transparent; +} +:global(html[data-desktop-platform='darwin'] .app.sidebar-collapsed .sidebar-rail) { + background: var(--bg); } :global(html[data-desktop-platform='win32'] .side), :global(html[data-desktop-platform='win32'] .sidebar-rail) { background: var(--panel); } -:global(html[data-desktop-platform='darwin'] .con), +:global(html[data-desktop-platform='darwin'] .con) { + position: relative; + padding-top: 20px; + background: var(--bg); + /* macOS now draws the window corners. */ +} +:global(html[data-desktop-platform='darwin'] .con::before) { + content: ''; + position: absolute; + top: 0; + right: 0; + left: 0; + height: 32px; + user-select: none; + -webkit-app-region: drag; +} :global(html[data-desktop-platform='darwin'] .global-preview) { background: var(--bg); } @@ -1468,6 +1492,7 @@ function openPr(url: string): void { .app > .side { grid-column: 1; grid-row: 1; + position: relative; overflow: hidden; } @@ -1506,6 +1531,7 @@ function openPr(url: string): void { .sidebar-rail { grid-column: 1; grid-row: 1; + position: relative; display: flex; flex-direction: column; align-items: center; @@ -1535,13 +1561,26 @@ function openPr(url: string): void { outline: 2px solid var(--blue); outline-offset: -2px; } +:global(html[data-desktop-platform='darwin'] .side) { + padding-top: 20px; +} :global(html[data-desktop-platform='darwin'] .sidebar-rail) { padding-top: 48px; - -webkit-app-region: drag; } :global(html[data-desktop-platform='darwin'] .sidebar-rail button) { -webkit-app-region: no-drag; } +:global(html[data-desktop-platform='darwin'] .side::before), +:global(html[data-desktop-platform='darwin'] .sidebar-rail::before) { + content: ''; + position: absolute; + top: 0; + right: 0; + left: 80px; + height: 32px; + user-select: none; + -webkit-app-region: drag; +} /* The collapsed rail occupies track 1; keep the main pane pinned to the conversation track even though the sidebar/handle are display:none. */ diff --git a/apps/pythinker-web/src/api/daemon/mappers.ts b/apps/pythinker-web/src/api/daemon/mappers.ts index 4b18ab42..d5ac96a6 100644 --- a/apps/pythinker-web/src/api/daemon/mappers.ts +++ b/apps/pythinker-web/src/api/daemon/mappers.ts @@ -707,6 +707,8 @@ export function toAppModel(wire: WireModel): AppModel { displayName: wire.display_name, maxContextSize: wire.max_context_size, capabilities: wire.capabilities, + supportEfforts: wire.support_efforts, + adaptiveThinking: wire.adaptive_thinking, }; } diff --git a/apps/pythinker-web/src/api/daemon/wire.ts b/apps/pythinker-web/src/api/daemon/wire.ts index 7d6aef1a..c0e61066 100644 --- a/apps/pythinker-web/src/api/daemon/wire.ts +++ b/apps/pythinker-web/src/api/daemon/wire.ts @@ -333,6 +333,8 @@ export interface WireModel { display_name?: string; max_context_size: number; capabilities?: string[]; + support_efforts?: string[]; + adaptive_thinking?: boolean; } export interface WireProvider { diff --git a/apps/pythinker-web/src/api/types.ts b/apps/pythinker-web/src/api/types.ts index b3ba5821..6c025c91 100644 --- a/apps/pythinker-web/src/api/types.ts +++ b/apps/pythinker-web/src/api/types.ts @@ -526,6 +526,10 @@ export interface AppModel { maxContextSize: number; /** Optional capability tags (e.g. ["vision", "thinking"]) */ capabilities?: string[]; + /** Effort levels this model offers; absent means fall back to low/medium/high. */ + supportEfforts?: string[]; + /** Model reasons adaptively rather than via an explicit thinking capability tag. */ + adaptiveThinking?: boolean; } export interface AppProvider { diff --git a/apps/pythinker-web/src/components/ChatDock.vue b/apps/pythinker-web/src/components/ChatDock.vue index eba261ec..7858839c 100644 --- a/apps/pythinker-web/src/components/ChatDock.vue +++ b/apps/pythinker-web/src/components/ChatDock.vue @@ -321,9 +321,16 @@ defineExpose({ loadForEdit }); padding-right: var(--panes-scrollbar-width, 0px); flex: none; position: relative; - background: var(--bg); + background: transparent; z-index: 10; } + +/* Use a different surface so the card radius reads against the page, especially in dark mode; --sh only exists on the modern and pythinker themes, hence the fallback. */ +.chat-dock :deep(.composer-card) { + background: var(--panel); + box-shadow: var(--sh, 0 6px 18px rgba(0, 0, 0, 0.28)); +} + .chat-dock.align-center { margin-left: auto; margin-right: auto; } .chat-dock.align-left { margin-left: 0; margin-right: auto; } .chat-dock.align-mobile { max-width: none; } diff --git a/apps/pythinker-web/src/components/ChatPane.vue b/apps/pythinker-web/src/components/ChatPane.vue index 6b2fa81f..127d844f 100644 --- a/apps/pythinker-web/src/components/ChatPane.vue +++ b/apps/pythinker-web/src/components/ChatPane.vue @@ -1030,8 +1030,6 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string { .u-edit[data-tooltip]::after, .u-edit[data-tooltip]::before { position: absolute; - left: 50%; - transform: translateX(-50%); pointer-events: none; opacity: 0; visibility: hidden; @@ -1042,6 +1040,8 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string { .u-edit[data-tooltip]::after { content: attr(data-tooltip); bottom: calc(100% + 6px); + right: 0; + left: auto; padding: 4px 8px; background: var(--ink); color: var(--bg); @@ -1053,6 +1053,8 @@ function renderBlockKey(block: AssistantRenderBlock, index: number): string { .u-edit[data-tooltip]::before { content: ''; bottom: calc(100% + 2px); + left: 50%; + transform: translateX(-50%); border-width: 4px; border-style: solid; border-color: var(--ink) transparent transparent transparent; diff --git a/apps/pythinker-web/src/components/Composer.vue b/apps/pythinker-web/src/components/Composer.vue index d7092026..e2eba130 100644 --- a/apps/pythinker-web/src/components/Composer.vue +++ b/apps/pythinker-web/src/components/Composer.vue @@ -9,7 +9,11 @@ import { buildSlashItems, filterCommands, parseSlash } from '../lib/slashCommand import type { FileItem } from './MentionMenu.vue'; import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../types'; import type { AppModel, AppSkill, ThinkingLevel } from '../api/types'; -import { modelThinkingAvailability } from '../lib/modelThinking'; +import { + coerceThinkingForModel, + effortLevelsForModel, + modelThinkingAvailability, +} from '../lib/modelThinking'; import { formatTokens } from '../lib/formatTokens'; // --------------------------------------------------------------------------- @@ -735,12 +739,18 @@ const hasUpload = computed(() => !!props.uploadImage); // --------------------------------------------------------------------------- const dropdownOpen = ref(false); +const modelPillRef = ref(null); +const modelDropdownStyle = ref>({}); const permDropdownOpen = ref(false); const toolbarRef = ref(null); function toggleDropdown(): void { dropdownOpen.value = !dropdownOpen.value; if (dropdownOpen.value) { + const rect = modelPillRef.value?.getBoundingClientRect(); + modelDropdownStyle.value = rect + ? { maxHeight: `${Math.min(360, Math.max(160, rect.top - 4 - 12))}px` } + : {}; permDropdownOpen.value = false; document.addEventListener('click', onDocClick, true); } else { @@ -811,15 +821,16 @@ const currentModel = computed(() => { ); }); const thinkingAvailability = computed(() => modelThinkingAvailability(currentModel.value)); -const thinkingToggleable = computed(() => thinkingAvailability.value === 'toggle'); -const thinkingOn = computed(() => { - if (thinkingAvailability.value === 'always-on') return true; - if (thinkingAvailability.value === 'unsupported') return false; - return (props.thinking ?? 'off') !== 'off'; -}); -function toggleThinking(): void { - if (!thinkingToggleable.value) return; - emit('setThinking', thinkingOn.value ? 'off' : 'high'); +const effortLevels = computed(() => effortLevelsForModel(currentModel.value)); +const currentEffort = computed(() => + coerceThinkingForModel(currentModel.value, props.thinking ?? 'off'), +); +function effortLabel(level: ThinkingLevel): string { + return t(`status.effortLevels.${level}`); +} +function selectEffort(level: ThinkingLevel): void { + emit('setThinking', level); + closeDropdown(); } // Plan toggle @@ -919,15 +930,19 @@ function selectModel(modelId: string): void {
- - - {{ att.name }} @@ -1179,6 +1194,7 @@ function selectModel(modelId: string): void { {{ status.model }} - {{ t('composer.thinkingSuffix') }} + {{ ` · ${effortLabel(currentEffort)}` }}
-