From a725bc4db23118c663ad16ae76e7b5ee47fbf55c Mon Sep 17 00:00:00 2001 From: PaiduiXiaowangzi Date: Mon, 17 Aug 2026 00:06:19 +0800 Subject: [PATCH] fix(tui): keep task preview output fresh --- .changeset/refresh-task-preview.md | 5 + .../tui/components/dialogs/tasks-browser.ts | 15 ++ .../src/tui/controllers/tasks-browser.ts | 152 +++++++++----- apps/kimi-code/test/tui/tasks-browser.test.ts | 196 +++++++++++++++++- 4 files changed, 318 insertions(+), 50 deletions(-) create mode 100644 .changeset/refresh-task-preview.md diff --git a/.changeset/refresh-task-preview.md b/.changeset/refresh-task-preview.md new file mode 100644 index 0000000000..324dd204b4 --- /dev/null +++ b/.changeset/refresh-task-preview.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep the selected background task preview up to date and show output loading errors separately from empty output. diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index 1b33a5bf3a..3a3c536ee4 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -39,6 +39,7 @@ export interface TasksBrowserProps { readonly selectedTaskId: string | undefined; readonly tailOutput: string | undefined; readonly tailLoading: boolean; + readonly tailError: string | undefined; readonly flashMessage: string | undefined; readonly onSelect: (taskId: string) => void; readonly onToggleFilter: () => void; @@ -600,6 +601,20 @@ export class TasksBrowserApp extends Container implements Focusable { return this.renderFrame('Preview Output', lines, width, height); } + if (this.props.tailError !== undefined && !this.props.tailLoading) { + const errorLines = sanitizeShellOutput( + `Cannot load preview: ${this.props.tailError}`, + ).split('\n'); + const styled = errorLines + .slice(0, Math.max(1, innerHeight - 1)) + .map((line) => currentTheme.fg('error', line)); + if (styled.length < innerHeight) { + styled.push(currentTheme.fg('textMuted', 'Press R to retry.')); + } + while (styled.length < innerHeight) styled.push(''); + return this.renderFrame('Preview Output', styled, width, height); + } + let body: string; if (this.props.tailLoading) body = '[loading…]'; else if (this.props.tailOutput === undefined || this.props.tailOutput.length === 0) diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 7db2f0a82f..3d3ee9b947 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -1,4 +1,8 @@ -import type { BackgroundTaskInfo, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { + BackgroundTaskInfo, + BackgroundTaskStatus, + Session, +} from '@moonshot-ai/kimi-code-sdk'; import type { ProcessTerminal, TUI } from '@moonshot-ai/pi-tui'; import { AgentActivityViewer, formatSubagentActivityPreview } from '../components/dialogs/agent-activity-viewer'; @@ -36,7 +40,10 @@ export type TasksBrowserState = { selectedTaskId: string | undefined; tailOutput: string | undefined; tailLoading: boolean; + tailError: string | undefined; tailRequestId: number; + tailRequestInFlight: boolean; + tailTaskStatus: BackgroundTaskStatus | undefined; flashMessage: string | undefined; flashTimer: NodeJS.Timeout | undefined; pollTimer: NodeJS.Timeout | undefined; @@ -78,13 +85,15 @@ export class TasksBrowserController { const filter: TasksFilter = 'all'; const selectedTaskId = this.pickInitialSelection(tasks, filter); + const selectedTask = tasks.find((task) => task.taskId === selectedTaskId); const component = new TasksBrowserApp( { tasks, filter, selectedTaskId, tailOutput: undefined, - tailLoading: false, + tailLoading: selectedTaskId !== undefined, + tailError: undefined, flashMessage: undefined, ...this.buildCallbacks(), }, @@ -105,8 +114,11 @@ export class TasksBrowserController { filter, selectedTaskId, tailOutput: undefined, - tailLoading: false, + tailLoading: selectedTaskId !== undefined, + tailError: undefined, tailRequestId: 0, + tailRequestInFlight: false, + tailTaskStatus: selectedTask?.status, flashMessage: undefined, flashTimer: undefined, pollTimer, @@ -114,7 +126,7 @@ export class TasksBrowserController { }); if (selectedTaskId !== undefined) { - this.loadTail(selectedTaskId); + void this.loadTail(selectedTaskId, selectedTask); } } @@ -200,7 +212,7 @@ export class TasksBrowserController { return candidates.find((t) => t.status === 'running')?.taskId ?? candidates[0]!.taskId; } - private async refresh(opts: { silent?: boolean } = {}): Promise { + async refresh(opts: { silent?: boolean; forceTail?: boolean } = {}): Promise { const { state } = this.host; const browser = state.tasksBrowser; if (browser === undefined) return; @@ -208,7 +220,7 @@ export class TasksBrowserController { const session = this.host.session; if (session === undefined) return; - let tasks: readonly BackgroundTaskInfo[]; + let tasks: readonly BackgroundTaskInfo[] | undefined; try { tasks = await session.listBackgroundTasks({ activeOnly: false }); } catch (error) { @@ -217,27 +229,49 @@ export class TasksBrowserController { `Refresh failed: ${error instanceof Error ? error.message : String(error)}`, ); } - return; } if (state.tasksBrowser !== browser) return; - this.syncAgentPreview(); - this.pushProps(tasks); + + const selectedTaskId = browser.selectedTaskId; + const selectedTask = + tasks?.find((task) => task.taskId === selectedTaskId) ?? + (selectedTaskId === undefined ? undefined : this.host.backgroundTasks.get(selectedTaskId)); + const previousStatus = browser.tailTaskStatus; + browser.tailTaskStatus = selectedTask?.status; + + const hasAgentPreview = this.syncAgentPreview(selectedTask); + this.pushProps(tasks ?? [...this.host.backgroundTasks.values()]); + + if (selectedTaskId === undefined || hasAgentPreview) return; + const reachedTerminalState = + selectedTask !== undefined && + selectedTask.status !== 'running' && + selectedTask.status !== previousStatus; + const forceTail = opts.forceTail === true || reachedTerminalState; + if (forceTail || selectedTask?.status === 'running') { + await this.loadTail(selectedTaskId, selectedTask, forceTail); + } } /** Agent tasks capture output only on completion, so while one is selected * the Preview frame is fed from the in-memory activity store instead. */ - private syncAgentPreview(): void { + private syncAgentPreview(info: BackgroundTaskInfo | undefined): boolean { const browser = this.host.state.tasksBrowser; const selectedTaskId = browser?.selectedTaskId; - if (browser === undefined || selectedTaskId === undefined) return; - const info = this.host.backgroundTasks.get(selectedTaskId); - if (info?.kind !== 'agent' || info.agentId === undefined) return; + if (browser === undefined || selectedTaskId === undefined) return false; + if (info?.kind !== 'agent' || info.agentId === undefined) return false; const record = this.host.sessionEventHandler.subAgentEventHandler.activityStore.get( info.agentId, ); - if (record === undefined) return; + if (record === undefined) return false; + if (browser.tailRequestInFlight) { + browser.tailRequestId += 1; + browser.tailRequestInFlight = false; + } browser.tailOutput = formatSubagentActivityPreview(record); browser.tailLoading = false; + browser.tailError = undefined; + return true; } private pushProps(tasks: readonly BackgroundTaskInfo[]): void { @@ -249,6 +283,7 @@ export class TasksBrowserController { selectedTaskId: browser.selectedTaskId, tailOutput: browser.tailOutput, tailLoading: browser.tailLoading, + tailError: browser.tailError, flashMessage: browser.flashMessage, ...this.buildCallbacks(), }); @@ -295,11 +330,16 @@ export class TasksBrowserController { const browser = this.host.state.tasksBrowser; if (browser === undefined) return; if (browser.selectedTaskId === taskId) return; + const info = this.host.backgroundTasks.get(taskId); browser.selectedTaskId = taskId; browser.tailOutput = undefined; browser.tailLoading = true; + browser.tailError = undefined; + browser.tailTaskStatus = info?.status; + browser.tailRequestId += 1; + browser.tailRequestInFlight = false; this.repaint(); - this.loadTail(taskId); + void this.loadTail(taskId, info); } private handleToggleFilter(): void { @@ -311,7 +351,13 @@ export class TasksBrowserController { private handleRefresh(): void { this.flash('Refreshing…', 600); - void this.refresh(); + const browser = this.host.state.tasksBrowser; + if (browser?.selectedTaskId !== undefined) { + browser.tailLoading = true; + browser.tailError = undefined; + this.repaint(); + } + void this.refresh({ forceTail: true }); } private async handleStop(taskId: string): Promise { @@ -327,7 +373,7 @@ export class TasksBrowserController { this.flash(`Stopping ${taskId}…`, 1500); try { await session.stopBackgroundTask(taskId, { reason: 'User initiated stop' }); - await this.refresh({ silent: true }); + await this.refresh({ silent: true, forceTail: true }); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.flash(`Stop failed: ${message}`); @@ -463,54 +509,62 @@ export class TasksBrowserController { state.ui.requestRender(); } - private loadTail(taskId: string): void { + private async loadTail( + taskId: string, + info: BackgroundTaskInfo | undefined, + force = false, + ): Promise { const { state } = this.host; const browser = state.tasksBrowser; if (browser === undefined) return; // Agent tasks capture output only on completion — serve the preview from // the in-memory activity store instead of the RPC when a record exists. - const info = this.host.backgroundTasks.get(taskId); - if (info !== undefined && info.kind === 'agent' && info.agentId !== undefined) { - const record = this.host.sessionEventHandler.subAgentEventHandler.activityStore.get( - info.agentId, - ); - if (record !== undefined) { - browser.tailOutput = formatSubagentActivityPreview(record); - browser.tailLoading = false; - this.repaint(); - return; - } + if (this.syncAgentPreview(info)) { + this.repaint(); + return; } const session = this.host.session; if (session === undefined) { browser.tailLoading = false; + browser.tailError = 'No active session.'; this.repaint(); return; } + if (browser.tailRequestInFlight) { + if (!force) return; + browser.tailRequestId += 1; + browser.tailRequestInFlight = false; + } const requestId = ++browser.tailRequestId; - void session - .getBackgroundTaskOutput(taskId, { tail: 4000 }) - .then((output) => { - const current = state.tasksBrowser; - if (current === undefined) return; - if (current !== browser || current.tailRequestId !== requestId) return; - if (current.selectedTaskId !== taskId) return; - current.tailOutput = output; - current.tailLoading = false; - this.repaint(); - }) - .catch(() => { - const current = state.tasksBrowser; - if (current === undefined) return; - if (current !== browser || current.tailRequestId !== requestId) return; - if (current.selectedTaskId !== taskId) return; - current.tailOutput = ''; - current.tailLoading = false; - this.repaint(); - }); + browser.tailRequestInFlight = true; + try { + const output = await session.getBackgroundTaskOutput(taskId, { tail: 4000 }); + const current = state.tasksBrowser; + if (current === undefined) return; + if (current !== browser || current.tailRequestId !== requestId) return; + if (current.selectedTaskId !== taskId) return; + const changed = + current.tailOutput !== output || current.tailLoading || current.tailError !== undefined; + current.tailOutput = output; + current.tailLoading = false; + current.tailError = undefined; + current.tailRequestInFlight = false; + if (changed) this.repaint(); + } catch (error) { + const current = state.tasksBrowser; + if (current === undefined) return; + if (current !== browser || current.tailRequestId !== requestId) return; + if (current.selectedTaskId !== taskId) return; + const message = error instanceof Error ? error.message : String(error); + const changed = current.tailError !== message || current.tailLoading; + current.tailLoading = false; + current.tailError = message; + current.tailRequestInFlight = false; + if (changed) this.repaint(); + } } private flash(message: string, durationMs = 2500): void { diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index d173af25ff..584adbb326 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -1,6 +1,13 @@ +/** + * Scenario: the /tasks browser renders and refreshes background-task previews. + * Responsibilities: layout, keyboard actions, preview states, refresh races, and agent viewers. + * Wiring: real TUI components/controllers with minimal terminal, UI shell, and SDK session stubs. + * Run: pnpm exec vitest run apps/kimi-code/test/tui/tasks-browser.test.ts + */ + import type { Terminal } from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus, Event } from '@moonshot-ai/kimi-code-sdk'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { TasksBrowserApp, @@ -67,6 +74,7 @@ function makeProps(overrides: Partial = {}): TasksBrowserProp selectedTaskId: undefined, tailOutput: undefined, tailLoading: false, + tailError: undefined, flashMessage: undefined, onSelect: vi.fn(), onToggleFilter: vi.fn(), @@ -269,6 +277,22 @@ describe('TasksBrowserApp — full-screen rendering', () => { expect(out).toContain('[loading'); }); + it('renders a retryable error when preview retrieval fails', () => { + const out = strip( + makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa' })], + selectedTaskId: 'bash-aaaaaaaa', + tailError: 'RPC unavailable', + }) + .render(120) + .join('\n'), + ); + + expect(out).toContain('Cannot load preview: RPC unavailable'); + expect(out).toContain('Press R to retry.'); + expect(out).not.toContain('[no output captured]'); + }); + it('shows empty-state copy in the Tasks pane when no tasks', () => { const out = strip(makeApp().render(120).join('\n')); expect(out).toContain('No background tasks'); @@ -557,6 +581,176 @@ describe('TasksBrowserApp — setProps', () => { }); }); +describe('TasksBrowserController — preview refresh', () => { + const controllers: TasksBrowserController[] = []; + + afterEach(() => { + for (const controller of controllers) controller.close(); + controllers.length = 0; + }); + + async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + } + + function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; + } + + function previewRig(options: { + tasks: readonly BackgroundTaskInfo[]; + listBackgroundTasks?: () => Promise; + getBackgroundTaskOutput: ( + taskId: string, + opts?: { tail?: number }, + ) => Promise; + }) { + const ui = { + children: [] as unknown[], + clear() { + this.children = []; + }, + addChild(child: unknown) { + this.children.push(child); + }, + setFocus: () => {}, + requestRender: () => {}, + }; + const state = { + tasksBrowser: undefined as unknown, + terminal: fakeTerminal(30), + ui, + editor: {}, + }; + const listBackgroundTasks = vi.fn( + options.listBackgroundTasks ?? (async () => options.tasks), + ); + const getBackgroundTaskOutput = vi.fn(options.getBackgroundTaskOutput); + const host = { + state, + backgroundTasks: new Map(options.tasks.map((item) => [item.taskId, item])), + sessionEventHandler: { + subAgentEventHandler: { activityStore: new SubagentActivityStore() }, + }, + session: { + listBackgroundTasks, + getBackgroundTaskOutput, + }, + showError: vi.fn(), + setTasksBrowser(value: unknown) { + state.tasksBrowser = value; + }, + }; + const controller = new TasksBrowserController(host as never); + controllers.push(controller); + + return { + controller, + getBackgroundTaskOutput, + component: () => + (state.tasksBrowser as { component: TasksBrowserApp }).component, + render: () => + strip( + (state.tasksBrowser as { component: TasksBrowserApp }).component + .render(120) + .join('\n'), + ), + }; + } + + it('updates the selected running preview when a silent refresh observes new output', async () => { + let output = 'starting'; + const rig = previewRig({ + tasks: [task({ taskId: 'bash-aaaaaaaa', status: 'running' })], + getBackgroundTaskOutput: async () => output, + }); + await rig.controller.show(); + await flushMicrotasks(); + + output = 'server ready'; + await rig.controller.refresh({ silent: true }); + + expect(rig.render()).toContain('server ready'); + expect(rig.getBackgroundTaskOutput).toHaveBeenCalledTimes(2); + expect(rig.getBackgroundTaskOutput).toHaveBeenLastCalledWith('bash-aaaaaaaa', { + tail: 4000, + }); + }); + + it('loads terminal output once when the selected task leaves the running state', async () => { + let terminal = false; + let output = 'partial output'; + const rig = previewRig({ + tasks: [task({ taskId: 'bash-aaaaaaaa', status: 'running' })], + listBackgroundTasks: async () => [ + task({ taskId: 'bash-aaaaaaaa', status: terminal ? 'completed' : 'running' }), + ], + getBackgroundTaskOutput: async () => output, + }); + await rig.controller.show(); + await flushMicrotasks(); + + terminal = true; + output = 'final output'; + await rig.controller.refresh({ silent: true }); + await rig.controller.refresh({ silent: true }); + + expect(rig.render()).toContain('final output'); + expect(rig.getBackgroundTaskOutput).toHaveBeenCalledTimes(2); + }); + + it('retries the selected preview when R is pressed after a retrieval error', async () => { + let attempt = 0; + const rig = previewRig({ + tasks: [task({ taskId: 'bash-aaaaaaaa', status: 'completed' })], + getBackgroundTaskOutput: async () => { + attempt += 1; + if (attempt === 1) throw new Error('RPC unavailable'); + return 'recovered output'; + }, + }); + await rig.controller.show(); + await flushMicrotasks(); + expect(rig.render()).toContain('Cannot load preview: RPC unavailable'); + + rig.component().handleInput('r'); + await flushMicrotasks(); + + expect(rig.render()).toContain('recovered output'); + expect(rig.render()).not.toContain('Cannot load preview'); + expect(rig.getBackgroundTaskOutput).toHaveBeenCalledTimes(2); + }); + + it('keeps the selected task preview when the previous task response arrives late', async () => { + const firstOutput = deferred(); + const rig = previewRig({ + tasks: [ + task({ taskId: 'bash-aaaaaaaa', status: 'running', startedAt: 1 }), + task({ taskId: 'bash-bbbbbbbb', status: 'running', startedAt: 2 }), + ], + getBackgroundTaskOutput: (taskId) => + taskId === 'bash-aaaaaaaa' ? firstOutput.promise : Promise.resolve('current output'), + }); + await rig.controller.show(); + + rig.component().handleInput('\u001B[B'); + await flushMicrotasks(); + expect(rig.render()).toContain('current output'); + + firstOutput.resolve('stale output'); + await flushMicrotasks(); + + expect(rig.render()).toContain('current output'); + expect(rig.render()).not.toContain('stale output'); + }); +}); + describe('TasksBrowserController — opening an agent task', () => { function makeControllerHost(tasks: BackgroundTaskInfo[], store: SubagentActivityStore) { const ui = {