From 9d1abf6a2befbff0a5c5235c6132c6bba2e8138a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 17:28:10 +0000 Subject: [PATCH 1/2] Group same-name processes in interactive mode Collapse identically named processes into one chooser row and offer a submenu to kill all or pick individuals. Port and PID searches stay exact. Co-authored-by: Wisernage --- cli.js | 1 + group-by-name.js | 17 +++++++++ interactive.js | 94 +++++++++++++++++++++++++++++++++++++++++++++--- package.json | 3 +- readme.md | 3 ++ test.js | 68 +++++++++++++++++++++++++++++++++++ 6 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 group-by-name.js diff --git a/cli.js b/cli.js index e65d553..9fb9f99 100755 --- a/cli.js +++ b/cli.js @@ -27,6 +27,7 @@ const cli = meow(` Run without arguments to use the interactive mode. In interactive mode, 🚦n% indicates high CPU usage and 🐏n% indicates high memory usage. Supports fuzzy search in the interactive mode. + Same-name processes are grouped (for example, "Google Chrome (12)"). Select a group to kill all or pick individuals. The process name is case-insensitive by default. `, { diff --git a/group-by-name.js b/group-by-name.js new file mode 100644 index 0000000..4353583 --- /dev/null +++ b/group-by-name.js @@ -0,0 +1,17 @@ +const groupByName = processes => { + const groups = new Map(); + + for (const process_ of processes) { + const existing = groups.get(process_.name); + + if (existing) { + existing.push(process_); + } else { + groups.set(process_.name, [process_]); + } + } + + return [...groups.values()]; +}; + +export default groupByName; diff --git a/interactive.js b/interactive.js index 4983d8d..4f8b2ab 100644 --- a/interactive.js +++ b/interactive.js @@ -10,6 +10,7 @@ import {allPortsWithPid} from 'pid-port'; import fkill from 'fkill'; import {processExists} from 'process-exists'; import FuzzySearch from 'fuzzy-search'; +import groupByName from './group-by-name.js'; const isWindows = process.platform === 'win32'; const commandLineMargins = 4; @@ -117,6 +118,46 @@ const renderProcessForDisplay = (process_, flags, memoryThreshold, cpuThreshold) }; }; +const renderProcessGroupForDisplay = groupedProcesses => { + const {name} = groupedProcesses[0]; + return { + name: `${name} (${groupedProcesses.length})`, + value: groupedProcesses, + }; +}; + +const renderIndividualProcessChoice = (process_, flags) => { + const pid = String(process_.pid); + + if (!flags.verbose || isWindows || !process_.cmd) { + return pid; + } + + const lineLength = process.stdout.columns || 80; + const cmd = cliTruncate(process_.cmd, Math.max(lineLength - pid.length - 1, 1), { + position: 'middle', + preferTruncationOnSpace: true, + }); + + return `${pid} ${cmd}`; +}; + +const shouldGroupSearchResults = term => !term.startsWith(':'); + +const createChooserChoices = (processes, flags, memoryThreshold, cpuThreshold, {group = true} = {}) => { + if (!group) { + return processes.map(process_ => renderProcessForDisplay(process_, flags, memoryThreshold, cpuThreshold)); + } + + return groupByName(processes).map(groupedProcesses => { + if (groupedProcesses.length === 1) { + return renderProcessForDisplay(groupedProcesses[0], flags, memoryThreshold, cpuThreshold); + } + + return renderProcessGroupForDisplay(groupedProcesses); + }); +}; + const searchProcessesByPort = (processes, port) => processes.filter(process_ => process_.ports.includes(port)); const searchProcessByPid = (processes, pid) => processes.find(process_ => String(process_.pid) === pid); @@ -251,21 +292,66 @@ const findPortsForProcess = (processId, portToPidMap) => { return ports; }; +const promptGroupedProcesses = async (processes, flags) => { + const {name} = processes[0]; + const {action} = await inquirer.prompt([{ + type: 'list', + name: 'action', + message: `${name} (${processes.length})`, + choices: [ + { + name: 'Kill all', + value: 'all', + }, + { + name: 'Pick individuals', + value: 'pick', + }, + ], + }]); + + if (action === 'all') { + await performKillSequence(processes.map(process_ => process_.pid)); + return; + } + + const {pids} = await inquirer.prompt([{ + type: 'checkbox', + name: 'pids', + message: 'Select processes to kill:', + choices: processes.map(process_ => ({ + name: renderIndividualProcessChoice(process_, flags), + value: process_.pid, + })), + }]); + + if (pids.length > 0) { + await performKillSequence(pids); + } +}; + const listProcesses = async (processes, flags) => { const memoryThreshold = flags.verbose ? 0 : 1; const cpuThreshold = flags.verbose ? 0 : 3; const searcher = new FuzzySearch(processes, ['name'], {caseSensitive: false}); - const selectedPid = await search({ + const selected = await search({ message: 'Running processes:', pageSize: 10, async source(term = '') { const matchingProcesses = filterAndSortProcesses(processes, term, searcher, flags); - return matchingProcesses.map(process_ => renderProcessForDisplay(process_, flags, memoryThreshold, cpuThreshold)); + return createChooserChoices(matchingProcesses, flags, memoryThreshold, cpuThreshold, { + group: shouldGroupSearchResults(term), + }); }, }); - performKillSequence(selectedPid); + if (Array.isArray(selected)) { + await promptGroupedProcesses(selected, flags); + return; + } + + await performKillSequence(selected); }; const init = async flags => { @@ -284,4 +370,4 @@ const init = async flags => { listProcesses(processesWithPorts, flags); }; -export {init, handleFkillError}; +export {init, handleFkillError, createChooserChoices, shouldGroupSearchResults}; diff --git a/package.json b/package.json index 25f5048..3831583 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ }, "files": [ "cli.js", - "interactive.js" + "interactive.js", + "group-by-name.js" ], "keywords": [ "cli-app", diff --git a/readme.md b/readme.md index 6bf6eae..fd2d7c1 100644 --- a/readme.md +++ b/readme.md @@ -44,6 +44,7 @@ $ fkill --help Run without arguments to use the interactive interface. In interactive mode, 🚦n% indicates high CPU usage and 🐏n% indicates high memory usage. Supports fuzzy search in the interactive mode. + Same-name processes are grouped (for example, "Google Chrome (12)"). Select a group to kill all or pick individuals. The process name is case-insensitive by default. ``` @@ -52,6 +53,8 @@ $ fkill --help Run `fkill` without arguments to launch the interactive UI. +Processes with the same name are grouped into one row, for example `Google Chrome (12)`. Selecting a group lets you kill all of them or pick individuals. Port (`:8080`) and PID searches stay ungrouped. + ![](screenshot.svg) ## Related diff --git a/test.js b/test.js index 2273327..f12a0ca 100644 --- a/test.js +++ b/test.js @@ -6,6 +6,8 @@ import delay from 'delay'; import noopProcess from 'noop-process'; import {processExists} from 'process-exists'; import getPort from 'get-port'; +import groupByName from './group-by-name.js'; +import {createChooserChoices, shouldGroupSearchResults} from './interactive.js'; const noopProcessKilled = async (t, pid) => { // Ensure the noop process has time to exit @@ -97,3 +99,69 @@ test('silent flag with -s shortflag works', async t => { const {exitCode} = await execa('./cli.js', ['-s', '--force', ':1337']); t.is(exitCode, 0); }); + +const chromeOne = { + name: 'Google Chrome', + cmd: 'Google Chrome --type=renderer', + pid: 101, + ports: [], + cpu: 0, + memory: 0, +}; + +const chromeTwo = { + name: 'Google Chrome', + cmd: 'Google Chrome --type=gpu-process', + pid: 102, + ports: [], + cpu: 0, + memory: 0, +}; + +const redis = { + name: 'redis', + cmd: 'redis-server', + pid: 201, + ports: ['6379'], + cpu: 0, + memory: 0, +}; + +test('groupByName groups same-name processes and keeps first-seen order', t => { + const groups = groupByName([chromeOne, redis, chromeTwo]); + + t.is(groups.length, 2); + t.deepEqual(groups[0], [chromeOne, chromeTwo]); + t.deepEqual(groups[1], [redis]); +}); + +test('groupByName returns empty array for no processes', t => { + t.deepEqual(groupByName([]), []); +}); + +test('groupByName keeps singleton names as single-item groups', t => { + t.deepEqual(groupByName([redis]), [[redis]]); +}); + +test('createChooserChoices collapses duplicate names and leaves singletons unchanged', t => { + const choices = createChooserChoices([chromeOne, chromeTwo, redis], {}, 1, 3); + + t.is(choices.length, 2); + t.is(choices[0].name, 'Google Chrome (2)'); + t.deepEqual(choices[0].value, [chromeOne, chromeTwo]); + t.is(choices[1].value, 201); +}); + +test('createChooserChoices can leave matching processes ungrouped', t => { + const choices = createChooserChoices([chromeOne, chromeTwo], {}, 1, 3, {group: false}); + + t.is(choices.length, 2); + t.is(choices[0].value, 101); + t.is(choices[1].value, 102); +}); + +test('port searches stay ungrouped; name searches are grouped', t => { + t.false(shouldGroupSearchResults(':8080')); + t.true(shouldGroupSearchResults('')); + t.true(shouldGroupSearchResults('chrome')); +}); From 949a198da5d5096fbbeeadec003d9c2a6aded204 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 17:29:32 +0000 Subject: [PATCH 2/2] Fix xo lint issues in grouping helpers Pack chooser options into one object and format named exports so xo passes. Co-authored-by: Wisernage --- interactive.js | 13 ++++++++++--- test.js | 11 +++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/interactive.js b/interactive.js index 4f8b2ab..69d6bfd 100644 --- a/interactive.js +++ b/interactive.js @@ -144,7 +144,7 @@ const renderIndividualProcessChoice = (process_, flags) => { const shouldGroupSearchResults = term => !term.startsWith(':'); -const createChooserChoices = (processes, flags, memoryThreshold, cpuThreshold, {group = true} = {}) => { +const createChooserChoices = (processes, flags, {memoryThreshold, cpuThreshold, group = true} = {}) => { if (!group) { return processes.map(process_ => renderProcessForDisplay(process_, flags, memoryThreshold, cpuThreshold)); } @@ -340,7 +340,9 @@ const listProcesses = async (processes, flags) => { pageSize: 10, async source(term = '') { const matchingProcesses = filterAndSortProcesses(processes, term, searcher, flags); - return createChooserChoices(matchingProcesses, flags, memoryThreshold, cpuThreshold, { + return createChooserChoices(matchingProcesses, flags, { + memoryThreshold, + cpuThreshold, group: shouldGroupSearchResults(term), }); }, @@ -370,4 +372,9 @@ const init = async flags => { listProcesses(processesWithPorts, flags); }; -export {init, handleFkillError, createChooserChoices, shouldGroupSearchResults}; +export { + init, + handleFkillError, + createChooserChoices, + shouldGroupSearchResults, +}; diff --git a/test.js b/test.js index f12a0ca..b85502b 100644 --- a/test.js +++ b/test.js @@ -144,7 +144,10 @@ test('groupByName keeps singleton names as single-item groups', t => { }); test('createChooserChoices collapses duplicate names and leaves singletons unchanged', t => { - const choices = createChooserChoices([chromeOne, chromeTwo, redis], {}, 1, 3); + const choices = createChooserChoices([chromeOne, chromeTwo, redis], {}, { + memoryThreshold: 1, + cpuThreshold: 3, + }); t.is(choices.length, 2); t.is(choices[0].name, 'Google Chrome (2)'); @@ -153,7 +156,11 @@ test('createChooserChoices collapses duplicate names and leaves singletons uncha }); test('createChooserChoices can leave matching processes ungrouped', t => { - const choices = createChooserChoices([chromeOne, chromeTwo], {}, 1, 3, {group: false}); + const choices = createChooserChoices([chromeOne, chromeTwo], {}, { + memoryThreshold: 1, + cpuThreshold: 3, + group: false, + }); t.is(choices.length, 2); t.is(choices[0].value, 101);