diff --git a/.changeset/stop-cron-after-close.md b/.changeset/stop-cron-after-close.md new file mode 100644 index 000000000..c488f588a --- /dev/null +++ b/.changeset/stop-cron-after-close.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Prevent cron ticks from continuing after an agent shuts down. diff --git a/.changeset/use-unicode-ellipsis.md b/.changeset/use-unicode-ellipsis.md new file mode 100644 index 000000000..45d628ddb --- /dev/null +++ b/.changeset/use-unicode-ellipsis.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Use the Unicode ellipsis in terminal status and truncation text. diff --git a/apps/pythinker-code/src/tui/commands/plugins.ts b/apps/pythinker-code/src/tui/commands/plugins.ts index 2c25a8f06..6638a056f 100644 --- a/apps/pythinker-code/src/tui/commands/plugins.ts +++ b/apps/pythinker-code/src/tui/commands/plugins.ts @@ -629,7 +629,7 @@ async function installFromPanel( if (official) { panel.setInstalling(truncateForStatus(label)); } else { - host.showStatus(`Installing or updating ${label} from marketplace...`); + host.showStatus(`Installing or updating ${label} from marketplace…`); } host.state.ui.requestRender(); try { diff --git a/apps/pythinker-code/src/tui/components/dialogs/compaction.ts b/apps/pythinker-code/src/tui/components/dialogs/compaction.ts index c0fa67ca2..a07a66dcf 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/compaction.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/compaction.ts @@ -165,7 +165,7 @@ export class CompactionComponent extends Container { return `${bullet}${label}`; } const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' '; - const label = currentTheme.boldFg('primary', 'Compacting context...'); + const label = currentTheme.boldFg('primary', 'Compacting context…'); const tip = this.tip ? currentTheme.fg('textDim', ` · Tip: ${this.tip}`) : ''; return `${bullet}${label}${tip}`; } diff --git a/apps/pythinker-code/src/tui/components/dialogs/question-dialog.ts b/apps/pythinker-code/src/tui/components/dialogs/question-dialog.ts index 1da283ea3..f1d426077 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/question-dialog.ts @@ -469,7 +469,7 @@ export class QuestionDialogComponent extends Container implements Focusable { appendWrapped(lines, ' ', ' ', bodyLine, renderWidth, dim); } if (bodyLines.length > visibleBodyLines.length) { - lines.push(dim(` ... ${String(bodyLines.length - visibleBodyLines.length)} more lines`)); + lines.push(dim(` … ${String(bodyLines.length - visibleBodyLines.length)} more lines`)); } } 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 ce39f75e0..ae44ac6ae 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/session-picker.ts @@ -264,7 +264,7 @@ export class SessionPickerComponent extends Container implements Focusable { if (this.loading) { lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS))); lines.push( - currentTheme.fg('textMuted', truncateToWidth('Loading sessions...', width, ELLIPSIS)), + currentTheme.fg('textMuted', truncateToWidth('Loading sessions…', width, ELLIPSIS)), ); lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; diff --git a/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress.ts b/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress.ts index 047274b9e..f31f73488 100644 --- a/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress.ts +++ b/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress.ts @@ -33,15 +33,15 @@ const AGENT_DYNAMIC_WORKFLOW_LEFT_INDENT = ' '; const AGENT_DYNAMIC_WORKFLOW_RIGHT_GAP = 1; const AGENT_DYNAMIC_WORKFLOW_NON_GRID_LINES = 6; const COMPACT_TERMINAL_MARK_WIDTH = 1; -const ORCHESTRATING_LABEL = 'Orchestrating...'; -const PROMPTING_LABEL = 'Prompting...'; -const WORKING_LABEL = 'Working...'; +const ORCHESTRATING_LABEL = 'Orchestrating…'; +const PROMPTING_LABEL = 'Prompting…'; +const WORKING_LABEL = 'Working…'; const COMPLETED_LABEL = 'Completed.'; const FAILED_LABEL = 'Failed.'; const ABORTED_LABEL = 'Aborted.'; const CANCELLED_LABEL = 'Cancelled.'; -const QUEUED_LABEL = 'Queued...'; -const SUSPENDED_LABEL = 'Rate limited...'; +const QUEUED_LABEL = 'Queued…'; +const SUSPENDED_LABEL = 'Rate limited…'; const RESUMED_ITEM_LABEL = '(resumed)'; const CANCELLED_LABEL_DARKEN_FACTOR = 0.72; const AGENT_ID_TOKENS = [ diff --git a/apps/pythinker-code/src/tui/components/messages/shell-run.ts b/apps/pythinker-code/src/tui/components/messages/shell-run.ts index 3cb58db91..3821179f5 100644 --- a/apps/pythinker-code/src/tui/components/messages/shell-run.ts +++ b/apps/pythinker-code/src/tui/components/messages/shell-run.ts @@ -17,7 +17,7 @@ const TIMER_INTERVAL_MS = 1000; const MAX_COMBINED_CHARS = 256 * 1024; const KEEP_COMBINED_CHARS = 64 * 1024; -const TRUNCATED_RUNNING_NOTICE = '... (output truncated)'; +const TRUNCATED_RUNNING_NOTICE = '… (output truncated)'; /** * Live view for a user-initiated `!` shell command. Two phases: diff --git a/apps/pythinker-code/src/tui/components/messages/thinking.ts b/apps/pythinker-code/src/tui/components/messages/thinking.ts index c3a10fd6b..597204b17 100644 --- a/apps/pythinker-code/src/tui/components/messages/thinking.ts +++ b/apps/pythinker-code/src/tui/components/messages/thinking.ts @@ -123,7 +123,7 @@ export class ThinkingComponent implements Component { rendered = []; } else { const contentLines = this.renderContent(width); - const hint = `... (${String(contentLines.length)} more lines, ctrl+o to expand)`; + const hint = `… (${String(contentLines.length)} more lines, ctrl+o to expand)`; const prefix = this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; const styledHint = currentTheme.fg( 'textDim', diff --git a/apps/pythinker-code/src/tui/components/messages/tool-call.ts b/apps/pythinker-code/src/tui/components/messages/tool-call.ts index 419bf63a7..bf1da147f 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-call.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-call.ts @@ -381,7 +381,7 @@ function truncateArgValue(key: string, value: string): string { // still tell which file is being touched. return '…' + value.slice(value.length - (MAX_ARG_LENGTH - 1)); } - return value.slice(0, MAX_ARG_LENGTH - 3) + '...'; + return value.slice(0, MAX_ARG_LENGTH - 1) + '…'; } function makeWorkspaceRelativePath(filePath: string, workspaceDir: string | undefined): string { @@ -813,7 +813,7 @@ export class ToolCallComponent extends Container { if (this.result !== undefined || text.length === 0) return; this.liveOutput += text; if (this.liveOutput.length > MAX_LIVE_OUTPUT_CHARS) { - this.liveOutput = `[...truncated]\n${this.liveOutput.slice( + this.liveOutput = `[…truncated]\n${this.liveOutput.slice( this.liveOutput.length - MAX_LIVE_OUTPUT_CHARS, )}`; } @@ -1422,7 +1422,7 @@ export class ToolCallComponent extends Container { const existingOutput = options?.replace === true ? '' : (activity?.output ?? ''); let output = existingOutput + text; if (output.length > MAX_LIVE_OUTPUT_CHARS) { - output = `[...truncated]\n${output.slice(output.length - MAX_LIVE_OUTPUT_CHARS)}`; + output = `[…truncated]\n${output.slice(output.length - MAX_LIVE_OUTPUT_CHARS)}`; } this.upsertSubToolActivity(id, name, args, activity?.phase ?? 'ongoing', output); this.rebuildContent(); @@ -1673,7 +1673,7 @@ export class ToolCallComponent extends Container { const suffix = this.hiddenSubCallCount > 1 ? 's' : ''; this.addChild( new Text( - currentTheme.italic(currentTheme.dim(` ${String(this.hiddenSubCallCount)} more tool call${suffix} ...`)), + currentTheme.italic(currentTheme.dim(` ${String(this.hiddenSubCallCount)} more tool call${suffix} …`)), 0, 0, ), @@ -2043,7 +2043,7 @@ export class ToolCallComponent extends Container { this.addChild( new Text( currentTheme.dim( - `... (${String(remaining)} more lines, ${String(allLines.length)} total, ctrl+o to expand)`, + `… (${String(remaining)} more lines, ${String(allLines.length)} total, ctrl+o to expand)`, ), 2, 0, @@ -2130,7 +2130,7 @@ export class ToolCallComponent extends Container { const elapsedSeconds = startedAtMs === undefined ? 0 : Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); const target = filePath.length > 0 ? ` for ${filePath}` : ''; - const progress = `Preparing changes${target}... ${formatByteSize(bytes)} · ${formatElapsed( + const progress = `Preparing changes${target}… ${formatByteSize(bytes)} · ${formatElapsed( elapsedSeconds, )} elapsed`; this.addChild(new Text(currentTheme.dim(progress), 2, 0)); diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts index c8bfb7588..db8f4d654 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -87,15 +87,15 @@ export class TruncatedOutputComponent implements Component { if (this.tail) { const shown = contentLines.slice(contentLines.length - this.maxLines); return [ - this.renderHint(width, `... (${String(remaining)} earlier lines)`), + this.renderHint(width, `… (${String(remaining)} earlier lines)`), ...shown, ]; } const shown = contentLines.slice(0, this.maxLines); const hint = this.expandHint - ? `... (${String(remaining)} more lines, ctrl+o to expand)` - : `... (${String(remaining)} more lines)`; + ? `… (${String(remaining)} more lines, ctrl+o to expand)` + : `… (${String(remaining)} more lines)`; return [...shown, this.renderHint(width, hint)]; } } diff --git a/apps/pythinker-code/src/tui/components/panes/btw-panel.ts b/apps/pythinker-code/src/tui/components/panes/btw-panel.ts index f55713a0c..70de37037 100644 --- a/apps/pythinker-code/src/tui/components/panes/btw-panel.ts +++ b/apps/pythinker-code/src/tui/components/panes/btw-panel.ts @@ -145,7 +145,7 @@ export class BtwPanelComponent implements Component { lines.push(...this.renderTurn(turn, width)); } if (this.turns.length === 0) { - lines.push(chalk.hex(currentTheme.palette.textDim)('Ready for a side question...')); + lines.push(chalk.hex(currentTheme.palette.textDim)('Ready for a side question…')); } lines.push(...this.renderTransientNotices(width)); return this.fitBodyLines(lines); @@ -213,7 +213,7 @@ export class BtwPanelComponent implements Component { : thinkingLines; lines.push(...visibleThinking); } else if (turn.error === undefined) { - lines.push(chalk.hex(currentTheme.palette.textDim)('Waiting for answer...')); + lines.push(chalk.hex(currentTheme.palette.textDim)('Waiting for answer…')); } if (turn.error !== undefined) { const error = chalk.hex(currentTheme.palette.error)(turn.error); diff --git a/apps/pythinker-code/src/tui/utils/background-agent-status.ts b/apps/pythinker-code/src/tui/utils/background-agent-status.ts index aa740fc6c..c2322e633 100644 --- a/apps/pythinker-code/src/tui/utils/background-agent-status.ts +++ b/apps/pythinker-code/src/tui/utils/background-agent-status.ts @@ -11,7 +11,7 @@ function normalizeBackgroundField(value: string | undefined): string | undefined const collapsed = value.trim().replaceAll(/\s+/g, ' '); if (collapsed.length === 0) return undefined; if (collapsed.length <= MAX_BACKGROUND_FIELD_LENGTH) return collapsed; - return `${collapsed.slice(0, MAX_BACKGROUND_FIELD_LENGTH - 3)}...`; + return `${collapsed.slice(0, MAX_BACKGROUND_FIELD_LENGTH - 1)}…`; } export function formatBackgroundAgentTranscript( diff --git a/apps/pythinker-code/src/tui/utils/background-task-status.ts b/apps/pythinker-code/src/tui/utils/background-task-status.ts index 20260b47f..422040761 100644 --- a/apps/pythinker-code/src/tui/utils/background-task-status.ts +++ b/apps/pythinker-code/src/tui/utils/background-task-status.ts @@ -20,7 +20,7 @@ function truncate(value: string | undefined): string | undefined { const collapsed = value.trim().replaceAll(/\s+/g, ' '); if (collapsed.length === 0) return undefined; if (collapsed.length <= MAX_DETAIL_LENGTH) return collapsed; - return `${collapsed.slice(0, MAX_DETAIL_LENGTH - 3)}...`; + return `${collapsed.slice(0, MAX_DETAIL_LENGTH - 1)}…`; } export type BackgroundTaskTranscriptPhase = 'started' | 'updated' | 'terminal'; diff --git a/apps/pythinker-code/test/tui/activity-pane.test.ts b/apps/pythinker-code/test/tui/activity-pane.test.ts index d11f17a9f..997a488ee 100644 --- a/apps/pythinker-code/test/tui/activity-pane.test.ts +++ b/apps/pythinker-code/test/tui/activity-pane.test.ts @@ -181,7 +181,7 @@ describe('updateActivityPane terminal progress', () => { expect(state.activitySpinner).not.toBeNull(); expect(state.activityContainer.children).toHaveLength(0); const rendered = strip(progress.render(80).join('\n')); - expect(rendered).toContain('Working...'); + expect(rendered).toContain('Working…'); expect(BRAILLE_SPINNER_FRAMES.some((frame) => rendered.includes(frame))).toBe(true); state.activitySpinner?.instance.stop(); @@ -212,8 +212,8 @@ describe('updateActivityPane terminal progress', () => { expect(state.activitySpinner).not.toBeNull(); expect(state.activityContainer.children).toHaveLength(1); const output = strip(progress.render(80).join('\n')); - expect(output).toContain(' Working...'); - expect(output).not.toContain('⣷ Working...'); + expect(output).toContain(' Working…'); + expect(output).not.toContain('⣷ Working…'); state.activitySpinner?.instance.stop(); driver.sessionEventHandler.clearAgentDynamicWorkflowProgress(); diff --git a/apps/pythinker-code/test/tui/components/dialogs/compaction.test.ts b/apps/pythinker-code/test/tui/components/dialogs/compaction.test.ts index 4f415bc32..eb213cfa2 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/compaction.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/compaction.test.ts @@ -20,7 +20,7 @@ describe('CompactionComponent', () => { const lines = component.render(120).map(strip); const text = lines.join('\n'); - expect(text).toContain('Compacting context...'); + expect(text).toContain('Compacting context…'); expect(text).toContain(' keep the recent files only'); } finally { component.dispose(); @@ -34,7 +34,7 @@ describe('CompactionComponent', () => { const lines = component.render(120).map(strip); const text = lines.join('\n'); - expect(text).toContain('Compacting context... · Tip: ctrl+s: steer mid-turn'); + expect(text).toContain('Compacting context… · Tip: ctrl+s: steer mid-turn'); } finally { component.dispose(); } @@ -65,7 +65,7 @@ describe('CompactionComponent', () => { const text = lines.join('\n'); expect(text).toContain('Compaction cancelled'); - expect(text).not.toContain('Compacting context...'); + expect(text).not.toContain('Compacting context…'); } finally { component.dispose(); } @@ -157,7 +157,7 @@ describe('CompactionComponent', () => { try { const headerOf = (): string => { - const line = component.render(120).find((l) => strip(l).includes('Compacting context...')); + const line = component.render(120).find((l) => strip(l).includes('Compacting context…')); if (line === undefined) throw new Error('header line not found'); return line; }; diff --git a/apps/pythinker-code/test/tui/components/messages/agent-dynamic-workflow-progress.test.ts b/apps/pythinker-code/test/tui/components/messages/agent-dynamic-workflow-progress.test.ts index dfba906d1..29e014eb3 100644 --- a/apps/pythinker-code/test/tui/components/messages/agent-dynamic-workflow-progress.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/agent-dynamic-workflow-progress.test.ts @@ -163,7 +163,7 @@ describe('AgentDynamicWorkflowProgressComponent', () => { expect(output).toContain('Agent DynamicWorkflow'); expect(output).toContain('Review changed files'); - expect(output).toContain('Orchestrating...'); + expect(output).toContain('Orchestrating…'); expect(output).not.toContain('01'); }); @@ -229,7 +229,7 @@ describe('AgentDynamicWorkflowProgressComponent', () => { expect(strip(rendered)).toContain('Agent DynamicWorkflow'); expect(strip(rendered)).toContain('001 ['); - expect(strip(rendered)).toContain('Working...'); + expect(strip(rendered)).toContain('Working…'); expect(rendered).toMatch(/\u001B\[38;2;\d+;\d+;\d+m━/); expect(memberColor('001')).toBeDefined(); expect(memberColor('002')).toBeDefined(); @@ -273,8 +273,8 @@ describe('AgentDynamicWorkflowProgressComponent', () => { const output = renderText(component); - expect(output).toContain('001 Queued...'); - expect(output).toContain('002 Queued...'); + expect(output).toContain('001 Queued…'); + expect(output).toContain('002 Queued…'); expect(output).not.toContain('001 ['); expect(output).not.toContain('002 ['); expect(output).not.toContain('agents=2'); @@ -286,11 +286,11 @@ describe('AgentDynamicWorkflowProgressComponent', () => { registerSubagents(component, 3); const lines = renderLines(component, 97); - const queuedLine = lines.find((line) => line.includes('001 Queued...')); + const queuedLine = lines.find((line) => line.includes('001 Queued…')); expect(queuedLine).toBeDefined(); - expect(queuedLine).toContain('002 Queued...'); - expect(queuedLine).toContain('003 Queued...'); + expect(queuedLine).toContain('002 Queued…'); + expect(queuedLine).toContain('003 Queued…'); }); it('omits subagent text when the compact grid is needed to fit available height', () => { @@ -388,7 +388,7 @@ describe('AgentDynamicWorkflowProgressComponent', () => { let output = renderText(component); expect(output).toContain('001 ['); expect(output).toContain('Running'); - expect(output).toContain('002 Queued...'); + expect(output).toContain('002 Queued…'); expect(output).not.toContain('002 ['); component.markCompleted('agent-1'); @@ -437,8 +437,8 @@ describe('AgentDynamicWorkflowProgressComponent', () => { }); let output = renderText(component); - expect(output).toContain('Rate limited...'); - expect(output).not.toContain('Queued...'); + expect(output).toContain('Rate limited…'); + expect(output).not.toContain('Queued…'); expect(output).not.toContain('Provider rate limit'); expect(output).not.toContain('Failed'); @@ -446,7 +446,7 @@ describe('AgentDynamicWorkflowProgressComponent', () => { output = renderText(component); expect(output).toContain('Running'); - expect(output).not.toContain('Rate limited...'); + expect(output).not.toContain('Rate limited…'); }); it('renders rate-limited subagents as cancelled when cancelled', () => { @@ -465,7 +465,7 @@ describe('AgentDynamicWorkflowProgressComponent', () => { expect(cellLine).toBeDefined(); expect(cellLine).toContain('⊘ Cancelled.'); - expect(cellLine).not.toContain('Rate limited...'); + expect(cellLine).not.toContain('Rate limited…'); }); it('renders failure details from AgentDynamicWorkflow result output', () => { @@ -630,7 +630,7 @@ describe('AgentDynamicWorkflowProgressComponent', () => { }); const promptLine = renderLines(prompting, 80) - .find((line) => line.includes('Prompting...')); + .find((line) => line.includes('Prompting…')); expect(promptLine).toBeDefined(); const working = createComponent(); @@ -638,15 +638,15 @@ describe('AgentDynamicWorkflowProgressComponent', () => { startSubagents(working, 1); const workingLine = renderLines(working, 80) - .find((line) => line.includes('Working...')); + .find((line) => line.includes('Working…')); expect(workingLine).toBeDefined(); const promptTextIndex = promptLine?.indexOf('Review the changed') ?? -1; const progressBarIndex = workingLine?.indexOf('━') ?? -1; expect(promptTextIndex).toBeGreaterThan(0); expect(progressBarIndex).toBeGreaterThan(0); - expect(promptTextIndex).toBe(visibleWidth(' Prompting... ')); - expect(progressBarIndex).toBe(visibleWidth(' Working... ')); + expect(promptTextIndex).toBe(visibleWidth(' Prompting… ')); + expect(progressBarIndex).toBe(visibleWidth(' Working… ')); }); it('renders the activity spinner before the total status line', () => { @@ -657,10 +657,10 @@ describe('AgentDynamicWorkflowProgressComponent', () => { component.setActivitySpinnerText(() => '⣷'); const statusLine = renderLines(component, 80) - .find((line) => line.includes('Working...')); + .find((line) => line.includes('Working…')); expect(statusLine).toBeDefined(); - expect(statusLine?.startsWith(' ⣷ Working...')).toBe(true); + expect(statusLine?.startsWith(' ⣷ Working…')).toBe(true); }); it('keeps a two-cell placeholder after the AgentDynamicWorkflow tool call ends', () => { @@ -673,10 +673,10 @@ describe('AgentDynamicWorkflowProgressComponent', () => { component.setActivitySpinnerText(() => '⣯'); const statusLine = renderLines(component, 80) - .find((line) => line.includes('Working...')); + .find((line) => line.includes('Working…')); expect(statusLine).toBeDefined(); - expect(statusLine?.startsWith(' Working...')).toBe(true); + expect(statusLine?.startsWith(' Working…')).toBe(true); expect(statusLine).not.toContain('⣷'); expect(statusLine).not.toContain('⣯'); }); @@ -719,7 +719,7 @@ describe('AgentDynamicWorkflowProgressComponent', () => { }); const promptLine = renderLines(prompting, 50) - .find((line) => line.includes('Prompting...')); + .find((line) => line.includes('Prompting…')); expect(promptLine).toBeDefined(); expect(visibleWidth(promptLine ?? '')).toBeLessThan(50); @@ -843,7 +843,7 @@ describe('AgentDynamicWorkflowProgressComponent', () => { registerSubagents(component, 1); let output = renderText(component); - expect(output).toContain('001 Queued...'); + expect(output).toContain('001 Queued…'); expect(output).not.toContain('001 ['); expect(output).not.toContain('002'); @@ -852,15 +852,15 @@ describe('AgentDynamicWorkflowProgressComponent', () => { description: `${DEFAULT_DESCRIPTION} #2 (coder)`, }); output = renderText(component); - expect(output).toContain('001 Queued...'); - expect(output).toContain('002 Queued...'); + expect(output).toContain('001 Queued…'); + expect(output).toContain('002 Queued…'); expect(output).not.toContain('001 ['); expect(output).not.toContain('002 ['); component.markInputComplete(); output = renderText(component); - expect(output).toContain('001 Queued...'); - expect(output).toContain('002 Queued...'); + expect(output).toContain('001 Queued…'); + expect(output).toContain('002 Queued…'); expect(output).not.toContain('001 ['); }); diff --git a/apps/pythinker-code/test/tui/components/messages/shell-execution.test.ts b/apps/pythinker-code/test/tui/components/messages/shell-execution.test.ts index bb501c05b..132737c86 100644 --- a/apps/pythinker-code/test/tui/components/messages/shell-execution.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/shell-execution.test.ts @@ -35,7 +35,7 @@ describe('ShellExecutionComponent', () => { expect(collapsedOutput).toContain('line1'); expect(collapsedOutput).toContain('line3'); expect(collapsedOutput).not.toContain('line4'); - expect(collapsedOutput).toContain('... (2 more lines, ctrl+o to expand)'); + expect(collapsedOutput).toContain('… (2 more lines, ctrl+o to expand)'); const expanded = new ShellExecutionComponent({ result: { @@ -76,7 +76,7 @@ describe('ShellExecutionComponent', () => { const output = component.render(100).map(strip).join('\n'); expect(output).toContain('hello'); - expect(output).not.toContain('... (2 more lines'); + expect(output).not.toContain('… (2 more lines'); }); it('preserves internal empty lines while trimming only trailing ones', () => { @@ -91,7 +91,7 @@ describe('ShellExecutionComponent', () => { const output = component.render(100).map(strip).join('\n'); expect(output).toContain('a'); expect(output).toContain('b'); - expect(output).not.toContain('... (2 more lines'); + expect(output).not.toContain('… (2 more lines'); }); it('truncates long single-line output by wrapped visual lines', () => { @@ -106,7 +106,7 @@ describe('ShellExecutionComponent', () => { const out = strip(component.render(20).join('\n')); expect(out).toContain('x'); expect(out).not.toContain('x'.repeat(500)); - expect(out).toContain('... ('); + expect(out).toContain('… ('); }); describe('shellExecutionResultRenderer', () => { diff --git a/apps/pythinker-code/test/tui/components/messages/shell-run.test.ts b/apps/pythinker-code/test/tui/components/messages/shell-run.test.ts index be89c6f03..96a4bc110 100644 --- a/apps/pythinker-code/test/tui/components/messages/shell-run.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/shell-run.test.ts @@ -90,7 +90,7 @@ describe('ShellRunComponent finished collapse', () => { const c = create(); c.finish(rows(30), '', false); const rendered = stripTheme(c.render(80).join('\n')); - expect(rendered).toContain('... (20 more lines, ctrl+o to expand)'); + expect(rendered).toContain('… (20 more lines, ctrl+o to expand)'); expect(rendered).toContain('row-01'); expect(rendered).toContain('row-10'); expect(rendered).not.toContain('row-11'); @@ -116,7 +116,7 @@ describe('ShellRunComponent finished collapse', () => { c.setExpanded(false); const collapsed = stripTheme(c.render(80).join('\n')); - expect(collapsed).toContain('... (20 more lines, ctrl+o to expand)'); + expect(collapsed).toContain('… (20 more lines, ctrl+o to expand)'); expect(collapsed).not.toContain('row-11'); }); @@ -153,7 +153,7 @@ describe('ShellRunComponent finished collapse', () => { c.append('x'.repeat(300 * 1024)); c.setExpanded(true); const rendered = stripTheme(c.render(80).join('\n')); - expect(rendered).toContain('... (output truncated)'); + expect(rendered).toContain('… (output truncated)'); }); it('keeps the backgrounded view when toggled', () => { @@ -168,7 +168,7 @@ describe('ShellRunComponent finished collapse', () => { const c = create(); c.finish(rows(30), 'boom', true); const collapsed = stripTheme(c.render(80).join('\n')); - expect(collapsed).toContain('... (21 more lines, ctrl+o to expand)'); + expect(collapsed).toContain('… (21 more lines, ctrl+o to expand)'); c.setExpanded(true); const expanded = stripTheme(c.render(80).join('\n')); diff --git a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts index b6aa53174..9ea1d79be 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts @@ -225,7 +225,7 @@ describe('ToolCallComponent', () => { expect(collapsed).toContain('line2'); expect(collapsed).toContain('line3'); expect(collapsed).not.toContain('line4'); - expect(collapsed).toContain('... (2 more lines, ctrl+o to expand)'); + expect(collapsed).toContain('… (2 more lines, ctrl+o to expand)'); component.setExpanded(true); @@ -254,6 +254,24 @@ describe('ToolCallComponent', () => { expect(out).toContain('line2'); }); + it('uses a Unicode ellipsis when truncating live Bash output', () => { + const component = new ToolCallComponent( + { + id: 'call_shell_live_truncated', + name: 'Bash', + args: { command: 'printf output' }, + }, + undefined, + ); + + component.setExpanded(true); + component.appendLiveOutput('x'.repeat(50_001)); + + const out = strip(component.render(1000).join('\n')); + expect(out).toContain('[…truncated]'); + expect(out).not.toContain('[...truncated]'); + }); + it('clears live Bash output when the final result arrives', () => { const component = new ToolCallComponent( { @@ -1751,7 +1769,7 @@ describe('ToolCallComponent', () => { const out = strip(component.render(100).join('\n')); expect(out).toContain('Using Edit'); expect(out).toContain('foo.ts'); - expect(out).toContain('Preparing changes for foo.ts...'); + expect(out).toContain('Preparing changes for foo.ts…'); expect(out).toContain('4s elapsed'); expect(out).toMatch(/\d+(?:\.\d+)? (?:B|KB|MB)/); expect(out).not.toContain('old20'); diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts index 04c3f73a9..9edafb94f 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -65,14 +65,14 @@ describe('tool-result registry', () => { expect(out).toContain('b'); expect(out).toContain('c'); expect(out).not.toContain('\nd'); - expect(out).toContain('... (2 more lines, ctrl+o to expand)'); + expect(out).toContain('… (2 more lines, ctrl+o to expand)'); }); it('uses truncated renderer for Bash to preserve raw output UX', () => { const renderer = pickResultRenderer('Bash'); const out = strip(joinRender(renderer(call('Bash'), result('one\ntwo\nthree\nfour'), ctx))); expect(out).toContain('one'); - expect(out).toContain('... (1 more lines, ctrl+o to expand)'); + expect(out).toContain('… (1 more lines, ctrl+o to expand)'); }); it('Read renders no body when collapsed (header chip carries the count)', () => { @@ -248,7 +248,7 @@ describe('tool-result registry', () => { const out = strip(joinRender(renderer(call('SomethingUnknown'), result(longLine), ctx), 20)); expect(out).toContain('x'); expect(out).not.toContain(longLine); - expect(out).toContain('... ('); + expect(out).toContain('… ('); }); const waitForCompletedOutput = [ diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/truncated.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/truncated.test.ts index 5f248ecfa..6ed026343 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/truncated.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/truncated.test.ts @@ -20,7 +20,7 @@ describe('TruncatedOutputComponent', () => { const lines = strip(component.render(80).join('\n')).split('\n'); expect(lines[0]?.startsWith(' a')).toBe(true); expect(lines[1]?.startsWith(' b')).toBe(true); - expect(lines[2]).toBe(' ... (3 more lines, ctrl+o to expand)'); + expect(lines[2]).toBe(' … (3 more lines, ctrl+o to expand)'); }); it('defaults to a two-space indent for both content and hint', () => { @@ -32,7 +32,7 @@ describe('TruncatedOutputComponent', () => { const lines = strip(component.render(80).join('\n')).split('\n'); expect(lines[0]?.startsWith(' x')).toBe(true); - expect(lines[1]).toBe(' ... (2 more lines, ctrl+o to expand)'); + expect(lines[1]).toBe(' … (2 more lines, ctrl+o to expand)'); }); it('omits the ctrl+o promise when expandHint is false', () => { @@ -45,7 +45,7 @@ describe('TruncatedOutputComponent', () => { }); const lines = strip(component.render(80).join('\n')).split('\n'); - expect(lines[2]).toBe(' ... (2 more lines)'); + expect(lines[2]).toBe(' … (2 more lines)'); }); it('renders all lines without a hint when expanded', () => { diff --git a/apps/pythinker-code/test/tui/message-replay.test.ts b/apps/pythinker-code/test/tui/message-replay.test.ts index 2d0d2f988..4e22ecdb8 100644 --- a/apps/pythinker-code/test/tui/message-replay.test.ts +++ b/apps/pythinker-code/test/tui/message-replay.test.ts @@ -395,7 +395,7 @@ describe('PythinkerTUI resume message replay', () => { ]); const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); - expect(transcript).toContain('... (20 more lines, ctrl+o to expand)'); + expect(transcript).toContain('… (20 more lines, ctrl+o to expand)'); expect(transcript).toContain('row-01'); expect(transcript).not.toContain('row-11'); }); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index 41ac26325..bc9476f3e 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -3832,7 +3832,7 @@ command = "vim" driver.handleUserInput('seq 30'); await vi.waitFor(() => { const transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); - expect(transcript).toContain('... (20 more lines, ctrl+o to expand)'); + expect(transcript).toContain('… (20 more lines, ctrl+o to expand)'); }); let transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); @@ -3846,7 +3846,7 @@ command = "vim" driver.state.editor.onToggleToolExpand?.(); transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); - expect(transcript).toContain('... (20 more lines, ctrl+o to expand)'); + expect(transcript).toContain('… (20 more lines, ctrl+o to expand)'); expect(transcript).not.toContain('row-11'); }); @@ -4289,7 +4289,7 @@ command = "vim" expect(session.startBtw).toHaveBeenCalledWith(); }); expect(session.prompt).not.toHaveBeenCalled(); - expect(stripSgr(renderBtwPanel(driver))).toContain('Ready for a side question...'); + expect(stripSgr(renderBtwPanel(driver))).toContain('Ready for a side question…'); driver.handleUserInput('What are you working on right now?'); @@ -4330,7 +4330,7 @@ command = "vim" await vi.waitFor(() => { expect(session.startBtw).toHaveBeenCalledWith(); }); - expect(stripSgr(renderBtwPanel(driver))).toContain('Ready for a side question...'); + expect(stripSgr(renderBtwPanel(driver))).toContain('Ready for a side question…'); driver.handleUserInput('check /skill:review'); @@ -4624,7 +4624,7 @@ command = "vim" const lines = getMountedBtwPanel(driver).render(80).map(stripSgr); expect(lines).toHaveLength(3); expect(lines.join('\n')).toContain('Q: side question'); - expect(lines.join('\n')).toContain('Waiting for answer...'); + expect(lines.join('\n')).toContain('Waiting for answer…'); }); it('keeps /btw panel height stable when final output is shorter than thinking', async () => { @@ -5623,7 +5623,7 @@ command = "vim" transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('001 ['); - expect(transcript).toContain('Queued...'); + expect(transcript).toContain('Queued…'); expect(transcript).not.toContain('Provider rate limit'); expect(transcript).not.toContain('Failed'); @@ -5662,7 +5662,7 @@ command = "vim" expect(transcript).toContain('001 ['); expect(transcript).toContain('Reviewing src/a.ts'); expect(transcript).not.toContain('Completed'); - expect(transcript).toContain('002 Queued...'); + expect(transcript).toContain('002 Queued…'); expect(transcript).not.toContain('002 ['); driver.sessionEventHandler.handleEvent( @@ -5984,7 +5984,7 @@ command = "vim" const renderDynamicWorkflow = (): string => stripSgr(dynamicWorkflowProgress.render(transcriptWidth).join('\n')); - expect(renderDynamicWorkflow()).toContain('001 Queued...'); + expect(renderDynamicWorkflow()).toContain('001 Queued…'); driver.sessionEventHandler.handleEvent( { @@ -6010,7 +6010,7 @@ command = "vim" .reduce((sum, child) => sum + child.render(transcriptWidth).length, 0); expect(rowsAfterDynamicWorkflowInTranscript).toBeGreaterThan(0); - expect(renderDynamicWorkflow()).toContain('001 Queued...'); + expect(renderDynamicWorkflow()).toContain('001 Queued…'); const transcript = stripSgr( driver.state.transcriptContainer.render(terminalColumns).join('\n'), ); @@ -6083,7 +6083,7 @@ command = "vim" let transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('Agent DynamicWorkflow'); - expect(transcript).toContain('Orchestrating...'); + expect(transcript).toContain('Orchestrating…'); expect(transcript).not.toContain('01'); driver.sessionEventHandler.handleEvent( @@ -6120,7 +6120,7 @@ command = "vim" ); transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('001 Queued...'); + expect(transcript).toContain('001 Queued…'); expect(transcript).not.toContain('001 ['); expect(transcript).toContain('002 src/b'); @@ -6142,8 +6142,8 @@ command = "vim" ); transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('001 Queued...'); - expect(transcript).toContain('002 Queued...'); + expect(transcript).toContain('001 Queued…'); + expect(transcript).toContain('002 Queued…'); expect(transcript).not.toContain('001 ['); expect(transcript).not.toContain('002 ['); }); diff --git a/apps/vscode/webview-ui/src/components/ActionMenu.tsx b/apps/vscode/webview-ui/src/components/ActionMenu.tsx index fe0586a66..d1e7ac56a 100644 --- a/apps/vscode/webview-ui/src/components/ActionMenu.tsx +++ b/apps/vscode/webview-ui/src/components/ActionMenu.tsx @@ -149,7 +149,7 @@ export function ActionMenu({ className, onAuthAction }: ActionMenuProps) { danger={isLoggedIn} > {loading ? : isLoggedIn ? : } - {loading ? "Processing..." : isLoggedIn ? "Sign out" : "Sign in"} + {loading ? "Processing…" : isLoggedIn ? "Sign out" : "Sign in"} diff --git a/apps/vscode/webview-ui/src/components/CompactionCard.tsx b/apps/vscode/webview-ui/src/components/CompactionCard.tsx index 256477447..a963501bd 100644 --- a/apps/vscode/webview-ui/src/components/CompactionCard.tsx +++ b/apps/vscode/webview-ui/src/components/CompactionCard.tsx @@ -15,7 +15,7 @@ export function CompactionCard() { )}
-
{isCompacting ? "Compacting context..." : "Context compacted"}
+
{isCompacting ? "Compacting context…" : "Context compacted"}
diff --git a/apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx b/apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx index 7b58ada07..ef8bf24b9 100644 --- a/apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx +++ b/apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx @@ -106,7 +106,7 @@ export function ConfigErrorScreen({ type, errorMessage, onRefresh, onBackToLogin
- Starting Pythinker Code... + Starting Pythinker Code…
diff --git a/apps/vscode/webview-ui/src/components/FilePickerMenu.tsx b/apps/vscode/webview-ui/src/components/FilePickerMenu.tsx index 44de1c030..a36ac6dd7 100644 --- a/apps/vscode/webview-ui/src/components/FilePickerMenu.tsx +++ b/apps/vscode/webview-ui/src/components/FilePickerMenu.tsx @@ -29,7 +29,7 @@ interface FilePickerMenuProps { function truncateMiddle(str: string, maxLen: number): string { if (str.length <= maxLen) return str; - const ellipsis = "..."; + const ellipsis = "…"; const charsToShow = maxLen - ellipsis.length; const frontChars = Math.ceil(charsToShow / 2); const backChars = Math.floor(charsToShow / 2); @@ -85,7 +85,7 @@ export function FilePickerMenu({ className={cn("w-full px-2 py-1.5 text-left flex items-center gap-2 border-b border-border", selectedIndex === 0 ? "bg-accent" : "hover:bg-accent/50")} > - Select images or videos... + Select images or videos… )} ) : ( @@ -131,7 +131,7 @@ export function FilePickerMenu({ )}
{isLoading ? ( -
Loading...
+
Loading…
) : items.length === 0 ? (
{mode === "search" ? "No files found" : "Empty folder"}
) : ( diff --git a/apps/vscode/webview-ui/src/components/LoginScreen.tsx b/apps/vscode/webview-ui/src/components/LoginScreen.tsx index 024f75f5d..b4cb52e87 100644 --- a/apps/vscode/webview-ui/src/components/LoginScreen.tsx +++ b/apps/vscode/webview-ui/src/components/LoginScreen.tsx @@ -42,7 +42,7 @@ export function LoginScreen({ onLoginSuccess, onSkip }: LoginScreenProps) {
- Waiting for authentication... + Waiting for authentication…

A browser window should open automatically. Complete the sign-in process there.

diff --git a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx index 71c58d2e9..86a98ddc9 100644 --- a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx +++ b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx @@ -190,7 +190,7 @@ export function QuestionDialog() { cardRef.current?.focus(); } }} - placeholder="Enter your response..." + placeholder="Enter your response…" className="flex-1 px-2 py-1 rounded-md text-xs border border-border bg-background outline-none focus:border-ring" /> )} {isMultiSelect && ( diff --git a/apps/vscode/webview-ui/src/components/SessionList.tsx b/apps/vscode/webview-ui/src/components/SessionList.tsx index f25935cb8..2f992e345 100644 --- a/apps/vscode/webview-ui/src/components/SessionList.tsx +++ b/apps/vscode/webview-ui/src/components/SessionList.tsx @@ -132,6 +132,7 @@ export function SessionList({ onClose }: SessionListProps) { const groupedSessions = useMemo(() => { const q = searchQuery.trim().toLowerCase(); const filtered = q ? sessions.filter((s) => cleanSystemTags(s.brief).toLowerCase().includes(q)) : sessions; + // oxlint-disable-next-line eslint-plugin-unicorn/no-array-sort -- The copied array is safe to sort in place. const sorted = [...filtered].sort((a, b) => (sortOrder === "recent" ? b.updatedAt - a.updatedAt : a.updatedAt - b.updatedAt)); const now = new Date(); @@ -221,7 +222,7 @@ export function SessionList({ onClose }: SessionListProps) {
- setSearchQuery(e.target.value)} className="pl-8 h-8 text-xs" /> + setSearchQuery(e.target.value)} className="pl-8 h-8 text-xs" />