Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/upstream-fix-batch-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"echadron": patch
---

Port a batch of upstream reliability fixes: the kap-server WebSocket now heartbeats so proxies stop dropping idle sessions, footer git-status commands resolve through PATH instead of the working directory, background task output is sanitized before display, the banner stays readable with long tags on narrow terminals, Windows explorer `/select,` handles quoted paths, question ids containing colons resolve, Gemini tool-call thought signatures keep their order, and a cron turn ending no longer hides the previous answer.
31 changes: 25 additions & 6 deletions apps/kimi-code/src/tui/components/chrome/banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import type { BannerState } from '#/tui/types';

const PREFIX_STAR = '✦';
const PADDING = ' ';
/**
* Minimum column count the main text gets next to an inline tag. A long tag
* (e.g. a full sentence from the remote banner config) can fit on the line
* yet leave only a sliver for the main text, which then wraps into a narrow,
* hard-broken column. When that would happen the tag moves onto its own line
* and the main text uses (nearly) the full width instead.
*/
const MIN_INLINE_MAIN_TEXT_WIDTH = 16;

export class BannerComponent implements Component {
constructor(private readonly state: BannerState) {}
Expand All @@ -30,14 +38,22 @@ export class BannerComponent implements Component {
const tagDisplay = tagStyled.length > 0 ? tagStyled + PADDING : '';
const tagWidth = visibleWidth(tagDisplay);
const showTag = tagWidth > 0 && tagWidth < width;
// Hanging indent aligning with the tag text (right after "✦ ").
const hangingWidth = visibleWidth(PREFIX_STAR + PADDING);
// If the inline tag would squeeze the main text into too narrow a column,
// render the tag on its own line and give the main text the full width.
const tagOnOwnLine = showTag && width - tagWidth < MIN_INLINE_MAIN_TEXT_WIDTH;
const inlineTag = showTag && !tagOnOwnLine;
// Body lines (continuations of the main text) indent to match the first
// line's main-text column, which starts right after the tag display.
const bodyIndent = showTag ? ' '.repeat(tagWidth) : '';
// line's main-text column, which starts right after the tag display. When
// the tag is on its own line, the main text aligns with the tag text.
const bodyIndent = inlineTag ? ' '.repeat(tagWidth) : tagOnOwnLine ? ' '.repeat(hangingWidth) : '';
// Descriptive subtext lines (the second line in the design) start at the
// column after the leading star + space, aligning with the tag text itself.
const descIndent = showTag ? ' '.repeat(visibleWidth(PREFIX_STAR + PADDING)) : '';
const bodyContentWidth = width - (showTag ? tagWidth : 0);
const descContentWidth = width - (showTag ? visibleWidth(PREFIX_STAR + PADDING) : 0);
const descIndent = showTag ? ' '.repeat(hangingWidth) : '';
const bodyContentWidth =
width - (inlineTag ? tagWidth : tagOnOwnLine ? hangingWidth : 0);
const descContentWidth = width - (showTag ? hangingWidth : 0);

if (bodyContentWidth <= 0) {
return [''];
Expand All @@ -47,11 +63,14 @@ export class BannerComponent implements Component {
const subSegments = this.state.subText ? this.state.subText.split('\n') : [];

const result: string[] = [];
if (tagOnOwnLine) {
result.push(tagStyled);
}
for (let i = 0; i < mainSegments.length; i++) {
const wrapped = wrapTextWithAnsi(mainSegments[i]!, bodyContentWidth);
for (let j = 0; j < wrapped.length; j++) {
const boldLine = main(wrapped[j]!);
if (i === 0 && j === 0 && showTag) {
if (i === 0 && j === 0 && inlineTag) {
result.push(tagDisplay + boldLine);
} else {
result.push(bodyIndent + boldLine);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi

import { currentTheme } from '#/tui/theme';
import { printableChar } from '@/tui/utils/printable-key';
import { sanitizeShellOutput } from '#/tui/utils/shell-output';

const ELLIPSIS = '…';

Expand Down Expand Up @@ -104,7 +105,7 @@ export class TaskOutputViewer extends Container implements Focusable {
}

private splitOutput(output: string): string[] {
return (output.length > 0 ? output : '[no output captured]').split('\n');
return (output.length > 0 ? sanitizeShellOutput(output) : '[no output captured]').split('\n');
}

// ── input ──────────────────────────────────────────────────────────
Expand Down
3 changes: 2 additions & 1 deletion apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi
import { SELECT_POINTER } from '@/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import { printableChar } from '@/tui/utils/printable-key';
import { sanitizeShellOutput } from '#/tui/utils/shell-output';

const ELLIPSIS = '…';

Expand Down Expand Up @@ -603,7 +604,7 @@ export class TasksBrowserApp extends Container implements Focusable {
if (this.props.tailLoading) body = '[loading…]';
else if (this.props.tailOutput === undefined || this.props.tailOutput.length === 0)
body = '[no output captured]';
else body = this.props.tailOutput;
else body = sanitizeShellOutput(this.props.tailOutput);

const rawLines = body.split('\n');
const tailLines = rawLines.slice(-innerHeight);
Expand Down
17 changes: 14 additions & 3 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2230,6 +2230,17 @@ export class KimiTUI {
return entry.turnId === undefined || entry.turnId.startsWith('replay:');
}

/**
* Fold-segment boundary: everything {@link isTurnBoundaryComponent} counts,
* plus the cron card. A cron-fired turn mounts no user message, so without
* the card as a boundary its output would share the previous user turn's
* fold segment — and the completed-turn assistant cap would fold that turn's
* final answer into the step summary.
*/
private isFoldSegmentBoundaryComponent(child: Component): boolean {
return this.isTurnBoundaryComponent(child) || child instanceof CronMessageComponent;
}

private trimTranscriptWindow(): boolean {
if (!TRANSCRIPT_WINDOW_ENABLED || TRANSCRIPT_MAX_TURNS <= 0) return false;
// Session replay already caps history to its own turn limit; trimming during
Expand Down Expand Up @@ -2330,10 +2341,10 @@ export class KimiTUI {
if (keepSteps <= 0 && keepAssistants <= 0) return false;
const children = this.state.transcriptContainer.children;

// Find the start of the current turn (last turn-starting user message).
// Find the start of the current fold segment.
let turnStart = -1;
for (let i = children.length - 1; i >= 0; i--) {
if (this.isTurnBoundaryComponent(children[i]!)) {
if (this.isFoldSegmentBoundaryComponent(children[i]!)) {
turnStart = i;
break;
}
Expand Down Expand Up @@ -2415,7 +2426,7 @@ export class KimiTUI {

const boundaries: number[] = [];
for (let i = 0; i < children.length; i++) {
if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i);
if (this.isFoldSegmentBoundaryComponent(children[i]!)) boundaries.push(i);
}
if (boundaries.length === 0) return;

Expand Down
44 changes: 30 additions & 14 deletions apps/kimi-code/src/utils/git/git-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

import { execFile, spawnSync } from 'node:child_process';

import { resolveCommandPath } from '#/utils/process/resolve-command';

const BRANCH_TTL_MS = 5_000;
const STATUS_TTL_MS = 15_000;
const PULL_REQUEST_TTL_MS = 60_000;
Expand Down Expand Up @@ -67,7 +69,11 @@ export function createGitStatusCache(
workDir: string,
options: GitStatusCacheOptions = {},
): GitStatusCache {
const isRepo = detectGitRepo(workDir);
// This cache is constructed before the workspace trust gate, so the git
// binary must be resolved through PATH to an absolute path — a bare name
// would let cmd.exe pick up a `git.exe` planted in the workspace.
const git = resolveCommandPath('git', workDir);
const isRepo = git !== undefined && detectGitRepo(git, workDir);
let branch: BranchState = { value: null, fetchedAt: 0 };
let status: StatusState = {
dirty: false,
Expand All @@ -87,16 +93,16 @@ export function createGitStatusCache(

return {
getStatus: () => {
if (!isRepo) return null;
if (!isRepo || git === undefined) return null;

const now = Date.now();
if (now - branch.fetchedAt >= BRANCH_TTL_MS) {
branch = { value: readBranch(workDir), fetchedAt: now };
branch = { value: readBranch(git, workDir), fetchedAt: now };
}
if (branch.value === null) return null;

if (now - status.fetchedAt >= STATUS_TTL_MS) {
status = { ...readStatus(workDir), fetchedAt: now };
status = { ...readStatus(git, workDir), fetchedAt: now };
}
refreshPullRequestIfNeeded(branch.value, now);

Expand Down Expand Up @@ -143,9 +149,9 @@ export function createGitStatusCache(
}
}

function detectGitRepo(workDir: string): boolean {
function detectGitRepo(git: string, workDir: string): boolean {
try {
const result = spawnSync('git', ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], {
const result = spawnSync(git, ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], {
encoding: 'utf8',
timeout: SPAWN_TIMEOUT_MS,
});
Expand All @@ -155,9 +161,9 @@ function detectGitRepo(workDir: string): boolean {
}
}

function readBranch(workDir: string): string | null {
function readBranch(git: string, workDir: string): string | null {
try {
const result = spawnSync('git', ['-C', workDir, 'branch', '--show-current'], {
const result = spawnSync(git, ['-C', workDir, 'branch', '--show-current'], {
encoding: 'utf8',
timeout: SPAWN_TIMEOUT_MS,
});
Expand All @@ -169,15 +175,18 @@ function readBranch(workDir: string): string | null {
}
}

function readStatus(workDir: string): {
function readStatus(
git: string,
workDir: string,
): {
dirty: boolean;
ahead: number;
behind: number;
diffAdded: number;
diffDeleted: number;
} {
try {
const result = spawnSync('git', ['-C', workDir, 'status', '--porcelain', '-b'], {
const result = spawnSync(git, ['-C', workDir, 'status', '--porcelain', '-b'], {
encoding: 'utf8',
timeout: SPAWN_TIMEOUT_MS,
maxBuffer: 4 * 1024 * 1024,
Expand All @@ -200,7 +209,7 @@ function readStatus(workDir: string): {
dirty = true;
}
}
const diff = dirty ? readDiffStats(workDir) : { added: 0, deleted: 0 };
const diff = dirty ? readDiffStats(git, workDir) : { added: 0, deleted: 0 };
return {
dirty,
ahead,
Expand All @@ -213,9 +222,9 @@ function readStatus(workDir: string): {
}
}

function readDiffStats(workDir: string): { added: number; deleted: number } {
function readDiffStats(git: string, workDir: string): { added: number; deleted: number } {
try {
const result = spawnSync('git', ['-C', workDir, 'diff', '--numstat', 'HEAD', '--'], {
const result = spawnSync(git, ['-C', workDir, 'diff', '--numstat', 'HEAD', '--'], {
encoding: 'utf8',
timeout: SPAWN_TIMEOUT_MS,
maxBuffer: 4 * 1024 * 1024,
Expand Down Expand Up @@ -244,9 +253,16 @@ function parseDiffNumstatCount(value: string | undefined): number {

function readPullRequest(workDir: string): Promise<PullRequestInfo | null> {
return new Promise((resolve) => {
// Resolve gh through PATH as well — this runs with cwd = workDir, where a
// planted `gh.exe` would otherwise be picked up by cmd.exe on Windows.
const gh = resolveCommandPath('gh', workDir);
if (gh === undefined) {
resolve(null);
return;
}
try {
execFile(
'gh',
gh,
['pr', 'view', '--json', 'number,url'],
{
cwd: workDir,
Expand Down
79 changes: 79 additions & 0 deletions apps/kimi-code/src/utils/process/resolve-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { accessSync, constants, statSync } from 'node:fs';
import { isAbsolute, join, relative, resolve } from 'node:path';

// cmd.exe / CreateProcess search the current directory before PATH, so on
// Windows a bare command name can execute a binary planted in the workspace
// the user just opened (binary planting). Resolving through PATH ourselves —
// and refusing any hit inside the cwd — keeps that from happening before the
// workspace trust gate has run.

const DEFAULT_WIN32_PATHEXT = ['.COM', '.EXE', '.BAT', '.CMD'];

function pathExtensions(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): readonly string[] {
if (platform !== 'win32') return [''];
const raw = env['PATHEXT'];
if (raw === undefined || raw.trim().length === 0) return DEFAULT_WIN32_PATHEXT;
return raw
.split(';')
.map((ext) => ext.trim())
.filter((ext) => ext.length > 0);
}

function candidateNames(command: string, extensions: readonly string[]): readonly string[] {
if (extensions.length === 1 && extensions[0] === '') return [command];
const lower = command.toLowerCase();
// An explicitly suffixed name (npm.cmd) is tried as-is first, like cmd.exe.
if (extensions.some((ext) => lower.endsWith(ext.toLowerCase()))) {
return [command, ...extensions.map((ext) => command + ext)];
}
return extensions.map((ext) => command + ext);
}

function isExecutableFile(candidate: string, platform: NodeJS.Platform): boolean {
try {
if (!statSync(candidate).isFile()) return false;
// Windows has no executable bit; file existence is enough there.
if (platform !== 'win32') accessSync(candidate, constants.X_OK);
return true;
} catch {
return false;
}
}

function isInsideCwd(candidate: string, cwd: string, platform: NodeJS.Platform): boolean {
let resolvedCandidate = resolve(candidate);
let resolvedCwd = resolve(cwd);
if (platform === 'win32') {
resolvedCandidate = resolvedCandidate.toLowerCase();
resolvedCwd = resolvedCwd.toLowerCase();
}
const rel = relative(resolvedCwd, resolvedCandidate);
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
}

/**
* Resolve a bare command name to an absolute executable path by searching
* PATH (PATHEXT-aware on Windows). Returns undefined when the command is not
* found — or when the only hit lives inside `cwd`, since executing that would
* run whatever a malicious workspace planted there.
*/
export function resolveCommandPath(command: string, cwd: string = process.cwd()): string | undefined {
const platform = process.platform;
const env = process.env;
const extensions = pathExtensions(platform, env);
const names = candidateNames(command, extensions);
const pathValue = env['PATH'] ?? '';
const separator = platform === 'win32' ? ';' : ':';
for (const dir of pathValue.split(separator)) {
// An empty PATH entry means the current directory on POSIX — anything it
// could produce would be rejected by the cwd check anyway, so skip it.
if (dir === '') continue;
for (const name of names) {
const candidate = join(dir, name);
if (!isExecutableFile(candidate, platform)) continue;
if (isInsideCwd(candidate, cwd, platform)) return undefined;
return resolve(candidate);
}
}
return undefined;
}
Loading
Loading